Skip to content

Commit 0458526

Browse files
feat(utils): integrate ollama to Chat
1 parent 77e4a71 commit 0458526

12 files changed

Lines changed: 279 additions & 25 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,9 @@ Single-test examples:
4242
## Standards
4343

4444
- TypeScript is `strict`; avoid implicit `any`
45-
- Tests use Vitest globals (do not import `vitest` except for types)
46-
- Enforce 100% test coverage; use `// v8 ignore` to exclude unreachable entrypoint guards
47-
- Use Conventional Commits: `type(scope): description`
48-
- Create a PR with `.github/PULL_REQUEST_TEMPLATE.md`
45+
- Use barrel files (`index.ts`) to consolidate related exports
46+
- Use `// v8 ignore` in tests to exclude unreachable entrypoint guards; use `vi.hoisted()` for mock variables accessed by `vi.mock()` hoisted scopes
47+
- Create PR with `.github/PULL_REQUEST_TEMPLATE.md`
4948

5049
## Verification
5150

eslint.config.mts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,35 @@ export default defineConfig([
4343

4444
rules: {
4545
'@typescript-eslint/no-extra-semi': 'off',
46+
'@typescript-eslint/no-restricted-imports': [
47+
'error',
48+
{
49+
paths: [
50+
{
51+
name: 'vitest',
52+
allowTypeImports: true,
53+
message: 'Use Vitest globals instead of importing from vitest',
54+
},
55+
],
56+
patterns: [
57+
{
58+
group: [
59+
'**/*.js',
60+
'**/*.jsx',
61+
'**/*.ts',
62+
'**/*.tsx',
63+
'**/*.mjs',
64+
'**/*.cjs',
65+
],
66+
message: 'Do not use file extensions in import paths',
67+
},
68+
],
69+
},
70+
],
4671
'@typescript-eslint/no-unused-vars': 'error',
4772
'no-console': 'error',
4873
'no-debugger': 'error',
74+
'no-restricted-imports': 'off',
4975
'prettier/prettier': 'error',
5076
'simple-import-sort/exports': 'error',
5177
'simple-import-sort/imports': 'error',

package-lock.json

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"cac": "7.0.0",
4343
"ink": "7.0.1",
4444
"ink-text-input": "6.0.0",
45+
"ollama": "0.6.3",
4546
"react": "19.2.5"
4647
},
4748
"devDependencies": {

src/cli.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type MockInstance, vi } from 'vitest';
1+
import type { MockInstance } from 'vitest';
22

33
const { clearScreen, outputHelp, parse, render } = vi.hoisted(() => ({
44
clearScreen: vi.fn(),

src/components/Chat.test.tsx

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,17 @@ import { render } from 'ink-testing-library';
22

33
import { Chat } from './Chat';
44

5+
vi.mock('../utils/ollama', () => ({
6+
streamChat: vi.fn().mockImplementation(function* () {
7+
yield 'Mocked';
8+
yield ' response';
9+
}),
10+
}));
11+
512
const ENTER = '\r';
613

7-
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
14+
const tick = (ms = 0) =>
15+
new Promise<void>((resolve) => setTimeout(resolve, ms));
816

917
type Stdin = ReturnType<typeof render>['stdin'];
1018

@@ -15,6 +23,11 @@ async function typeText(stdin: Stdin, text: string) {
1523
}
1624
}
1725

26+
async function waitForStream() {
27+
// Allow time for async generator to yield values
28+
await tick(10);
29+
}
30+
1831
describe('Chat', () => {
1932
it('renders input prompt', () => {
2033
const { lastFrame } = render(<Chat />);
@@ -25,19 +38,20 @@ describe('Chat', () => {
2538
const { lastFrame, stdin } = render(<Chat />);
2639
await typeText(stdin, 'hello');
2740
stdin.write(ENTER);
28-
await tick();
41+
await waitForStream();
2942
expect(lastFrame()).toContain('hello');
3043
});
3144

3245
it('clears input after submit', async () => {
3346
const { lastFrame, stdin } = render(<Chat />);
3447
await typeText(stdin, 'hello');
3548
stdin.write(ENTER);
36-
await tick();
49+
await waitForStream();
3750
const frame = lastFrame() ?? '';
38-
const inputLine =
39-
frame.split('\n').find((line) => line.includes('>')) ?? '';
40-
expect(inputLine.replace('>', '').trim()).toBe('');
51+
// Find the last line that contains just the prompt (no user text after >)
52+
const lines = frame.split('\n');
53+
const inputLine = lines.find((line) => line.trim() === '>') ?? '';
54+
expect(inputLine.trim()).toBe('>');
4155
});
4256

4357
it('does not add blank messages', async () => {
@@ -56,14 +70,51 @@ describe('Chat', () => {
5670
const { lastFrame, stdin } = render(<Chat />);
5771
await typeText(stdin, 'first');
5872
stdin.write(ENTER);
59-
await tick();
73+
await waitForStream();
6074
await typeText(stdin, 'second');
6175
stdin.write(ENTER);
62-
await tick();
76+
await waitForStream();
6377
const frame = lastFrame() ?? '';
6478
const firstIdx = frame.indexOf('first');
6579
const secondIdx = frame.indexOf('second');
6680
expect(firstIdx).toBeGreaterThanOrEqual(0);
6781
expect(secondIdx).toBeGreaterThan(firstIdx);
6882
});
6983
});
84+
85+
describe('Chat with error', () => {
86+
it('shows error message when stream fails with Error', async () => {
87+
const { streamChat } = await import('../utils/ollama');
88+
vi.mocked(streamChat).mockImplementationOnce(async function* () {
89+
await Promise.resolve();
90+
yield '';
91+
throw new Error('Connection failed');
92+
});
93+
94+
const { lastFrame, stdin } = render(<Chat />);
95+
96+
await typeText(stdin, 'hello');
97+
stdin.write(ENTER);
98+
await waitForStream();
99+
100+
expect(lastFrame()).toContain('Error: Connection failed');
101+
});
102+
103+
it('shows error message when stream fails with non-Error', async () => {
104+
const { streamChat } = await import('../utils/ollama');
105+
vi.mocked(streamChat).mockImplementationOnce(async function* () {
106+
await Promise.resolve();
107+
yield '';
108+
// eslint-disable-next-line @typescript-eslint/only-throw-error
109+
throw { toString: () => 'Custom error' };
110+
});
111+
112+
const { lastFrame, stdin } = render(<Chat />);
113+
114+
await typeText(stdin, 'hello');
115+
stdin.write(ENTER);
116+
await waitForStream();
117+
118+
expect(lastFrame()).toContain('Error: Custom error');
119+
});
120+
});

src/components/Chat.tsx

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,82 @@
11
import { Box, Text } from 'ink';
22
import TextInput from 'ink-text-input';
3-
import { useState } from 'react';
3+
import { useCallback, useState } from 'react';
4+
5+
import { ollama } from '../utils';
46

57
export function Chat() {
6-
const [messages, setMessages] = useState<string[]>([]);
8+
const [messages, setMessages] = useState<ollama.Message[]>([]);
79
const [input, setInput] = useState('');
10+
const [isLoading, setIsLoading] = useState(false);
11+
12+
const handleSubmit = useCallback(
13+
async (value: string) => {
14+
const userContent = value.trim();
15+
if (!userContent) return;
16+
17+
setInput('');
18+
setIsLoading(true);
819

9-
function handleSubmit(value: string) {
10-
if (value.trim()) {
11-
setMessages((prev) => [...prev, value.trim()]);
12-
}
13-
setInput('');
14-
}
20+
const userMessage: ollama.Message = {
21+
role: 'user',
22+
content: userContent,
23+
};
24+
setMessages((prev) => [...prev, userMessage]);
25+
26+
const updatedMessages = [...messages, userMessage];
27+
const assistantMessage: ollama.Message = {
28+
role: 'assistant',
29+
content: '',
30+
};
31+
setMessages((prev) => [...prev, assistantMessage]);
32+
33+
try {
34+
for await (const chunk of ollama.streamChat(updatedMessages)) {
35+
assistantMessage.content += chunk;
36+
setMessages((prev) => {
37+
const newMessages = [...prev];
38+
newMessages[newMessages.length - 1] = { ...assistantMessage };
39+
return newMessages;
40+
});
41+
}
42+
} catch (error) {
43+
assistantMessage.content = `Error: ${error instanceof Error ? error.message : String(error)}`;
44+
setMessages((prev) => {
45+
const newMessages = [...prev];
46+
newMessages[newMessages.length - 1] = { ...assistantMessage };
47+
return newMessages;
48+
});
49+
} finally {
50+
setIsLoading(false);
51+
}
52+
},
53+
[messages],
54+
);
1555

1656
return (
1757
<Box flexDirection="column">
1858
<Box flexDirection="column">
1959
{messages.map((message, index) => (
20-
<Text key={index}>{message}</Text>
60+
<Text key={index} color={message.role === 'user' ? 'green' : 'blue'}>
61+
{message.role === 'user' ? '> ' : ''}
62+
{message.content}
63+
</Text>
2164
))}
65+
{isLoading && messages[messages.length - 1]?.content === '' && (
66+
<Text color="yellow">...</Text>
67+
)}
2268
</Box>
2369

2470
<Box>
2571
<Text>&gt; </Text>
26-
<TextInput value={input} onChange={setInput} onSubmit={handleSubmit} />
72+
<TextInput
73+
value={input}
74+
onChange={setInput}
75+
onSubmit={(value) => {
76+
void handleSubmit(value);
77+
}}
78+
focus={!isLoading}
79+
/>
2780
</Box>
2881
</Box>
2982
);

src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export * as ollama from './ollama';
12
export * as screen from './screen';

src/utils/ollama.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
const { mockChat, mockList } = vi.hoisted(() => ({
2+
mockChat: vi.fn(),
3+
mockList: vi.fn(),
4+
}));
5+
6+
vi.mock('ollama', () => ({
7+
Ollama: class MockOllama {
8+
chat(...args: unknown[]) {
9+
return mockChat(...args) as Promise<AsyncIterable<unknown>>;
10+
}
11+
12+
list(...args: unknown[]) {
13+
return mockList(...args) as Promise<unknown>;
14+
}
15+
},
16+
}));
17+
18+
import { listModels, streamChat } from './ollama';
19+
20+
describe('ollama', () => {
21+
beforeEach(() => {
22+
mockChat.mockResolvedValue({
23+
async *[Symbol.asyncIterator]() {
24+
await Promise.resolve();
25+
yield { message: { content: 'Hello' } };
26+
},
27+
});
28+
mockList.mockResolvedValue({
29+
models: [{ name: 'codellama' }, { name: 'llama2' }],
30+
});
31+
});
32+
33+
describe('streamChat', () => {
34+
it('should yield content from stream', async () => {
35+
const messages = [{ role: 'user' as const, content: 'hello' }];
36+
const results: string[] = [];
37+
38+
for await (const chunk of streamChat(messages, 'codellama')) {
39+
results.push(chunk);
40+
}
41+
42+
expect(results).toEqual(['Hello']);
43+
});
44+
45+
it('should skip chunks with empty content', async () => {
46+
// Override mock to yield empty content first
47+
mockChat.mockResolvedValueOnce({
48+
async *[Symbol.asyncIterator]() {
49+
await Promise.resolve();
50+
yield { message: { content: '' } };
51+
yield { message: { content: 'Non-empty' } };
52+
},
53+
});
54+
55+
// Need to re-import to get a fresh client with new mock
56+
const { streamChat: streamChatWithEmpty } = await import('./ollama');
57+
const messages = [{ role: 'user' as const, content: 'hello' }];
58+
const results: string[] = [];
59+
60+
for await (const chunk of streamChatWithEmpty(messages, 'codellama')) {
61+
results.push(chunk);
62+
}
63+
64+
expect(results).toEqual(['Non-empty']);
65+
});
66+
});
67+
68+
describe('listModels', () => {
69+
it('should return list of models', async () => {
70+
const models = await listModels();
71+
expect(models).toEqual(['codellama', 'llama2']);
72+
});
73+
});
74+
});

0 commit comments

Comments
 (0)