base.ts 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. import type { LLMClient, LLMMessage, LLMResponse, OnStreamProgress } from "../llm/provider.js";
  2. import { chatCompletion } from "../llm/provider.js";
  3. import { searchWeb, fetchUrl } from "../utils/web-search.js";
  4. import type { Logger } from "../utils/logger.js";
  5. export interface AgentContext {
  6. readonly client: LLMClient;
  7. readonly model: string;
  8. readonly projectRoot: string;
  9. readonly bookId?: string;
  10. readonly logger?: Logger;
  11. readonly onStreamProgress?: OnStreamProgress;
  12. }
  13. export abstract class BaseAgent {
  14. protected readonly ctx: AgentContext;
  15. constructor(ctx: AgentContext) {
  16. this.ctx = ctx;
  17. }
  18. protected get log() {
  19. return this.ctx.logger;
  20. }
  21. protected async chat(
  22. messages: ReadonlyArray<LLMMessage>,
  23. options?: { readonly temperature?: number; readonly maxTokens?: number },
  24. ): Promise<LLMResponse> {
  25. return chatCompletion(this.ctx.client, this.ctx.model, messages, {
  26. ...options,
  27. onStreamProgress: this.ctx.onStreamProgress,
  28. });
  29. }
  30. /**
  31. * Chat with web search enabled.
  32. * OpenAI: uses native web_search_options / web_search_preview.
  33. * Other providers: searches via Tavily API (TAVILY_API_KEY), injects results into prompt.
  34. */
  35. protected async chatWithSearch(
  36. messages: ReadonlyArray<LLMMessage>,
  37. options?: { readonly temperature?: number; readonly maxTokens?: number },
  38. ): Promise<LLMResponse> {
  39. // OpenAI has native search — use it directly
  40. if (this.ctx.client.provider === "openai") {
  41. return chatCompletion(this.ctx.client, this.ctx.model, messages, {
  42. ...options,
  43. webSearch: true,
  44. onStreamProgress: this.ctx.onStreamProgress,
  45. });
  46. }
  47. // Other providers: self-hosted search → inject results into prompt
  48. const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
  49. if (!lastUserMsg) {
  50. return this.chat(messages, options);
  51. }
  52. try {
  53. // Extract search query from user message (first 200 chars)
  54. const query = lastUserMsg.content.slice(0, 200);
  55. this.log?.info(`[search] Searching: ${query.slice(0, 60)}...`);
  56. const results = await searchWeb(query, 3);
  57. if (results.length === 0) {
  58. this.log?.warn("[search] No results found, falling back to regular chat");
  59. return this.chat(messages, options);
  60. }
  61. // Fetch top result for full content
  62. let fullContent = "";
  63. try {
  64. fullContent = await fetchUrl(results[0]!.url, 4000);
  65. } catch {
  66. // Fetch failed, use snippets only
  67. }
  68. const searchContext = [
  69. "## Web Search Results\n",
  70. ...results.map((r, i) => `${i + 1}. **${r.title}**\n ${r.url}\n ${r.snippet}`),
  71. ...(fullContent ? [`\n## Full Content (Top Result)\n${fullContent}`] : []),
  72. ].join("\n");
  73. // Inject search results before the last user message
  74. const augmentedMessages: LLMMessage[] = messages.map((m) =>
  75. m === lastUserMsg
  76. ? { ...m, content: `${searchContext}\n\n---\n\n${m.content}` }
  77. : m,
  78. );
  79. return this.chat(augmentedMessages, options);
  80. } catch (e) {
  81. this.log?.warn(`[search] Search failed: ${e}, falling back to regular chat`);
  82. return this.chat(messages, options);
  83. }
  84. }
  85. abstract get name(): string;
  86. }