Как всё начиналось
Вы знаете это чувство, когда хочешь поиграться с локальными LLM через Ollama, но твой старенький ноутбук начинает плавиться при попытке запустить что-то крупнее 7B параметров? Решение: у Ollama есть облачный API!
Почему бы не сделать удобный клиент, который будет работать как прокси между моими приложениями и облачными моделями? Так родился проект ollama-client.
Архитектура не так проста, как кажется
Проект состоит из двух частей: бэк на Spring Boot и фронт на паре React и TypeScript.
Бэкенд (Spring Boot 3.5.10)
java
@RestController
@RequestMapping("/api/chat")
public class ChatController {
@PostMapping(value = "/stream", produces = "text/event-stream;charset=UTF-8")
public SseEmitter streamChat(@RequestBody ChatRequest request) {
// Вроде бы обычный стриминг...
return chatService.streamChat(request);
}
}
На первый взгляд типичный REST-контроллер. Но дьявол, как обычно, в деталях. Посмотрите внимательно на WebConfig.java:
java
@Override
public void preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (request.getRequestURI().contains("/api/chat/stream")) {
response.setHeader("X-Accel-Buffering", "no");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Connection", "keep-alive");
}
}
Эти заголовки — ключ к пониманию того, с чем нам пришлось бороться. Nginx (который часто стоит перед приложениями) любит буферизировать ответы, убивая всю магию Server-Sent Events. А нам нужен живой поток!
Фронтенд (React + TypeScript)
typescript
export const useEventStream = ({ url, onComplete, onError }: UseEventStreamProps) => {
// Казалось бы, используем EventSource...
// Но нет, пришлось изобретать велосипед
};
Подводные камни облачного API
Когда я начал интеграцию с Ollama Cloud, меня ждал сюрприз: их API не совсем соответствует тому, что ожидает стандартный Spring AI клиент. Пришлось делать ручной парсинг стрима:
java
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("data: ") && !line.equals("data: [DONE]")) {
// Парсим каждый чанк вручную
JsonNode chunk = objectMapper.readTree(line.substring(6));
// ...
}
}
Каждая модель в облаке имеет суффикс -cloud, и это тоже пришлось учитывать:
java
private String enhanceModelName(String modelName) {
// Если модель из облака — добавляем суффикс
if (isCloudModel(modelName)) {
return modelName + "-cloud";
}
return modelName;
}
Пасхалка и признание
А теперь самое интересное. Я должен признаться: на момент написания статьи стриминг в этом проекте работает с ошибкой. Да-да, вы не ослышались.
Проблема в кастомном хуке useEventStream. Посмотрите внимательно на код:
typescript
// Создаем URL с параметрами для POST запроса
// EventSource поддерживает только GET, поэтому нам нужно использовать
// другой подход или модифицировать бэкенд для поддержки GET с параметрами
Мы используем fetch вместо EventSource, но забыли правильно обработать завершение потока. В результате последний чанк может потеряться, а соединение закрывается преждевременно.
Как это исправить
Предлагаю сообществу помочь с решением. Вот правильная реализация хука:
typescript
export const useEventStream = ({ url, onComplete, onError }: UseEventStreamProps) => {
const [stream, setStream] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const startStream = useCallback(async (data: any) => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
setIsStreaming(true);
setStream('');
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
signal: abortControllerRef.current.signal
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
// Важно: обрабатываем остаток в буфере
if (buffer.trim()) {
processLine(buffer);
}
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.slice(5).trim();
if (jsonStr && jsonStr !== '[DONE]') {
try {
const data = JSON.parse(jsonStr);
if (data.message) {
setStream(prev => prev + data.message);
}
} catch (e) {
console.error('Parse error:', e);
}
}
}
}
}
} catch (error) {
if (error.name !== 'AbortError') {
onError?.(error);
}
} finally {
setIsStreaming(false);
onComplete?.();
}
}, [url]);
const stopStream = useCallback(() => {
abortControllerRef.current?.abort();
}, []);
return { stream, isStreaming, startStream, stopStream };
};
Docker-оркестрация: всё под контролем
Проект полностью докеризирован. У нас два docker-compose файла:
docker-compose.local.yml— только PostgreSQL для локальной разработки;docker-compose.yml— полный стек с бэкендом и фронтендом.
yaml
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: chatdb
POSTGRES_USER: chatuser
POSTGRES_PASSWORD: chatpass
volumes:
- postgres_data:/var/lib/postgresql/data
Что дальше
Планы по развитию проекта:
- Исправить стриминг.
- Добавить сохранение истории чатов в БД (уже есть entity, осталось дописать логику).
- Сделать выбор нескольких моделей в одном чате.
- Добавить поддержку системных промптов.
Заключение
Этот проект — отличный пример того, как можно комбинировать современные технологии: Spring Boot 3, React с TypeScript, Docker — и при этом работать с передовыми AI-моделями через облачный API. Да, в нём есть баги, но разве не в этом прелесть open source? Мы учимся на ошибках и делаем продукты лучше вместе.
Ссылка на репозиторий: https://gitverse.ru/nickolden/ollama-client.
Жду ваших issue и pull request! И помните: если ваш стриминг работает с первого раза — вы что-то делаете не так 😉