@larkup/workspace-server 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +2 -2
  3. package/dist-types/app/api/chat/feedback/route.d.ts +4 -0
  4. package/dist-types/app/api/chat/feedback/route.d.ts.map +1 -0
  5. package/dist-types/app/api/chat/route.d.ts +3 -0
  6. package/dist-types/app/api/chat/route.d.ts.map +1 -0
  7. package/dist-types/app/api/chat/tools.d.ts +67 -0
  8. package/dist-types/app/api/chat/tools.d.ts.map +1 -0
  9. package/dist-types/app/api/config/test-embedding/route.d.ts +3 -0
  10. package/dist-types/app/api/config/test-embedding/route.d.ts.map +1 -0
  11. package/dist-types/app/api/config/verify/route.d.ts +3 -0
  12. package/dist-types/app/api/config/verify/route.d.ts.map +1 -0
  13. package/dist-types/app/api/describe-image/route.d.ts +4 -0
  14. package/dist-types/app/api/describe-image/route.d.ts.map +1 -0
  15. package/dist-types/app/api/doc-editor/edit/route.d.ts +10 -0
  16. package/dist-types/app/api/doc-editor/edit/route.d.ts.map +1 -0
  17. package/dist-types/app/api/doc-editor/export/route.d.ts +9 -0
  18. package/dist-types/app/api/doc-editor/export/route.d.ts.map +1 -0
  19. package/dist-types/app/api/doc-editor/parse/route.d.ts +10 -0
  20. package/dist-types/app/api/doc-editor/parse/route.d.ts.map +1 -0
  21. package/dist-types/app/api/doc-editor/restore/route.d.ts +2 -0
  22. package/dist-types/app/api/doc-editor/restore/route.d.ts.map +1 -0
  23. package/dist-types/app/api/document/sign/route.d.ts +2 -0
  24. package/dist-types/app/api/document/sign/route.d.ts.map +1 -0
  25. package/dist-types/app/api/files/remote/route.d.ts +5 -0
  26. package/dist-types/app/api/files/remote/route.d.ts.map +1 -0
  27. package/dist-types/app/api/mcp/[connectionId]/route.d.ts +9 -0
  28. package/dist-types/app/api/mcp/[connectionId]/route.d.ts.map +1 -0
  29. package/dist-types/app/api/mcp/[connectionId]/test/route.d.ts +8 -0
  30. package/dist-types/app/api/mcp/[connectionId]/test/route.d.ts.map +1 -0
  31. package/dist-types/app/api/mcp/route.d.ts +4 -0
  32. package/dist-types/app/api/mcp/route.d.ts.map +1 -0
  33. package/dist-types/app/api/parse-file/route.d.ts +6 -0
  34. package/dist-types/app/api/parse-file/route.d.ts.map +1 -0
  35. package/dist-types/app/api/pdf-page/route.d.ts +4 -0
  36. package/dist-types/app/api/pdf-page/route.d.ts.map +1 -0
  37. package/dist-types/app/api/proxy/verify/route.d.ts +4 -0
  38. package/dist-types/app/api/proxy/verify/route.d.ts.map +1 -0
  39. package/dist-types/app/api/search/bing/route.d.ts +5 -0
  40. package/dist-types/app/api/search/bing/route.d.ts.map +1 -0
  41. package/dist-types/app/api/search/brave/route.d.ts +5 -0
  42. package/dist-types/app/api/search/brave/route.d.ts.map +1 -0
  43. package/dist-types/app/api/search/exa/route.d.ts +5 -0
  44. package/dist-types/app/api/search/exa/route.d.ts.map +1 -0
  45. package/dist-types/app/api/search/google/route.d.ts +30 -0
  46. package/dist-types/app/api/search/google/route.d.ts.map +1 -0
  47. package/dist-types/app/api/search/route.d.ts +7 -0
  48. package/dist-types/app/api/search/route.d.ts.map +1 -0
  49. package/dist-types/app/api/search/tavily/route.d.ts +5 -0
  50. package/dist-types/app/api/search/tavily/route.d.ts.map +1 -0
  51. package/dist-types/app/api/search/verify/route.d.ts +4 -0
  52. package/dist-types/app/api/search/verify/route.d.ts.map +1 -0
  53. package/dist-types/app/api/tabular/route.d.ts +9 -0
  54. package/dist-types/app/api/tabular/route.d.ts.map +1 -0
  55. package/dist-types/lib/chat/assistant-source-scope.d.ts +10 -0
  56. package/dist-types/lib/chat/assistant-source-scope.d.ts.map +1 -0
  57. package/dist-types/lib/chat/conversation-memory.d.ts +80 -0
  58. package/dist-types/lib/chat/conversation-memory.d.ts.map +1 -0
  59. package/dist-types/lib/chat/gateway-fallbacks.d.ts +7 -0
  60. package/dist-types/lib/chat/gateway-fallbacks.d.ts.map +1 -0
  61. package/dist-types/lib/chat/image-description.d.ts +10 -0
  62. package/dist-types/lib/chat/image-description.d.ts.map +1 -0
  63. package/dist-types/lib/chat/media-retrieval-routing.d.ts +14 -0
  64. package/dist-types/lib/chat/media-retrieval-routing.d.ts.map +1 -0
  65. package/dist-types/lib/chat/media-source-routing.d.ts +53 -0
  66. package/dist-types/lib/chat/media-source-routing.d.ts.map +1 -0
  67. package/dist-types/lib/chat/message-input.d.ts +8 -0
  68. package/dist-types/lib/chat/message-input.d.ts.map +1 -0
  69. package/dist-types/lib/chat/model-provider.d.ts +23 -0
  70. package/dist-types/lib/chat/model-provider.d.ts.map +1 -0
  71. package/dist-types/lib/chat/response-style.d.ts +2 -0
  72. package/dist-types/lib/chat/response-style.d.ts.map +1 -0
  73. package/dist-types/lib/chat/retrieval-ranking.d.ts +32 -0
  74. package/dist-types/lib/chat/retrieval-ranking.d.ts.map +1 -0
  75. package/dist-types/lib/chat/retrieval-routing.d.ts +39 -0
  76. package/dist-types/lib/chat/retrieval-routing.d.ts.map +1 -0
  77. package/dist-types/lib/chat/tabular-dataset-routing.d.ts +15 -0
  78. package/dist-types/lib/chat/tabular-dataset-routing.d.ts.map +1 -0
  79. package/dist-types/lib/chat/tabular-query-plan.d.ts +12 -0
  80. package/dist-types/lib/chat/tabular-query-plan.d.ts.map +1 -0
  81. package/dist-types/lib/chat/tabular-routing.d.ts +41 -0
  82. package/dist-types/lib/chat/tabular-routing.d.ts.map +1 -0
  83. package/dist-types/lib/chat/tabular-sandbox-files.d.ts +33 -0
  84. package/dist-types/lib/chat/tabular-sandbox-files.d.ts.map +1 -0
  85. package/dist-types/lib/chat/tabular-visualization.d.ts +31 -0
  86. package/dist-types/lib/chat/tabular-visualization.d.ts.map +1 -0
  87. package/dist-types/lib/chat/tool-call-repair.d.ts +11 -0
  88. package/dist-types/lib/chat/tool-call-repair.d.ts.map +1 -0
  89. package/dist-types/lib/chat/tool-context.d.ts +72 -0
  90. package/dist-types/lib/chat/tool-context.d.ts.map +1 -0
  91. package/dist-types/lib/chat/tool-registry.d.ts +3 -0
  92. package/dist-types/lib/chat/tool-registry.d.ts.map +1 -0
  93. package/dist-types/lib/chat/video-rag-routing.d.ts +33 -0
  94. package/dist-types/lib/chat/video-rag-routing.d.ts.map +1 -0
  95. package/dist-types/lib/chat/visual-routing.d.ts +53 -0
  96. package/dist-types/lib/chat/visual-routing.d.ts.map +1 -0
  97. package/dist-types/lib/enterprise-client.d.ts +31 -0
  98. package/dist-types/lib/enterprise-client.d.ts.map +1 -0
  99. package/dist-types/lib/marketplace/tool-runtime-config.d.ts +7 -0
  100. package/dist-types/lib/marketplace/tool-runtime-config.d.ts.map +1 -0
  101. package/dist-types/lib/media/knowledge.d.ts +47 -0
  102. package/dist-types/lib/media/knowledge.d.ts.map +1 -0
  103. package/dist-types/lib/media/pdf-inspection.d.ts +30 -0
  104. package/dist-types/lib/media/pdf-inspection.d.ts.map +1 -0
  105. package/dist-types/lib/media/source-identity.d.ts +12 -0
  106. package/dist-types/lib/media/source-identity.d.ts.map +1 -0
  107. package/dist-types/lib/media/source-utils.d.ts +157 -0
  108. package/dist-types/lib/media/source-utils.d.ts.map +1 -0
  109. package/dist-types/lib/media/video/quick-look.d.ts +60 -0
  110. package/dist-types/lib/media/video/quick-look.d.ts.map +1 -0
  111. package/dist-types/lib/media/video-intelligence-adapter.d.ts +521 -0
  112. package/dist-types/lib/media/video-intelligence-adapter.d.ts.map +1 -0
  113. package/dist-types/lib/media/video-intelligence-connection.d.ts +32 -0
  114. package/dist-types/lib/media/video-intelligence-connection.d.ts.map +1 -0
  115. package/dist-types/lib/remote-files.d.ts +12 -0
  116. package/dist-types/lib/remote-files.d.ts.map +1 -0
  117. package/dist-types/lib/search/provider-verification.d.ts +5 -0
  118. package/dist-types/lib/search/provider-verification.d.ts.map +1 -0
  119. package/dist-types/runtime-host.d.ts +20 -0
  120. package/dist-types/runtime-host.d.ts.map +1 -0
  121. package/package.json +338 -3
  122. package/src/app/api/chat/feedback/route.ts +77 -0
  123. package/src/app/api/chat/route.ts +2277 -0
  124. package/src/app/api/chat/tools.ts +3563 -0
  125. package/src/app/api/config/test-embedding/route.ts +39 -0
  126. package/src/app/api/config/verify/route.ts +309 -0
  127. package/src/app/api/describe-image/route.ts +108 -0
  128. package/src/app/api/doc-editor/edit/route.ts +87 -0
  129. package/src/app/api/doc-editor/export/route.ts +32 -0
  130. package/src/app/api/doc-editor/parse/route.ts +74 -0
  131. package/src/app/api/doc-editor/restore/route.ts +70 -0
  132. package/src/app/api/document/sign/route.ts +55 -0
  133. package/src/app/api/files/remote/route.ts +32 -0
  134. package/src/app/api/mcp/[connectionId]/route.ts +29 -0
  135. package/src/app/api/mcp/[connectionId]/test/route.ts +13 -0
  136. package/src/app/api/mcp/route.ts +22 -0
  137. package/src/app/api/parse-file/route.ts +86 -0
  138. package/src/app/api/pdf-page/route.ts +40 -0
  139. package/src/app/api/proxy/verify/route.ts +78 -0
  140. package/src/app/api/search/bing/route.ts +86 -0
  141. package/src/app/api/search/brave/route.ts +88 -0
  142. package/src/app/api/search/exa/route.ts +100 -0
  143. package/src/app/api/search/google/route.ts +147 -0
  144. package/src/app/api/search/route.ts +28 -0
  145. package/src/app/api/search/tavily/route.ts +93 -0
  146. package/src/app/api/search/verify/route.ts +27 -0
  147. package/src/app/api/tabular/route.ts +68 -0
  148. package/src/lib/chat/assistant-source-scope.ts +32 -0
  149. package/src/lib/chat/conversation-memory.ts +621 -0
  150. package/src/lib/chat/gateway-fallbacks.ts +12 -0
  151. package/src/lib/chat/image-description.ts +21 -0
  152. package/src/lib/chat/media-retrieval-routing.ts +37 -0
  153. package/src/lib/chat/media-source-routing.ts +100 -0
  154. package/src/lib/chat/message-input.ts +48 -0
  155. package/src/lib/chat/model-provider.ts +157 -0
  156. package/src/lib/chat/response-style.ts +3 -0
  157. package/src/lib/chat/retrieval-ranking.ts +126 -0
  158. package/src/lib/chat/retrieval-routing.ts +221 -0
  159. package/src/lib/chat/tabular-dataset-routing.ts +93 -0
  160. package/src/lib/chat/tabular-query-plan.ts +195 -0
  161. package/src/lib/chat/tabular-routing.ts +122 -0
  162. package/src/lib/chat/tabular-sandbox-files.ts +78 -0
  163. package/src/lib/chat/tabular-visualization.ts +165 -0
  164. package/src/lib/chat/tool-call-repair.ts +84 -0
  165. package/src/lib/chat/tool-context.ts +1222 -0
  166. package/src/lib/chat/tool-registry.ts +6 -0
  167. package/src/lib/chat/video-rag-routing.ts +149 -0
  168. package/src/lib/chat/visual-routing.ts +225 -0
  169. package/src/lib/enterprise-client.ts +96 -0
  170. package/src/lib/marketplace/tool-runtime-config.ts +155 -0
  171. package/src/lib/media/knowledge.ts +489 -0
  172. package/src/lib/media/pdf-inspection.ts +280 -0
  173. package/src/lib/media/source-identity.ts +50 -0
  174. package/src/lib/media/source-utils.ts +1156 -0
  175. package/src/lib/media/video/quick-look.ts +368 -0
  176. package/src/lib/media/video-intelligence-adapter.ts +1946 -0
  177. package/src/lib/media/video-intelligence-connection.ts +151 -0
  178. package/src/lib/remote-files.ts +182 -0
  179. package/src/lib/search/provider-verification.ts +95 -0
  180. package/src/runtime-host.ts +31 -0
@@ -0,0 +1,100 @@
1
+ import { buildQueryTermMatchers, countMatchesIn } from './retrieval-ranking';
2
+
3
+ export type RoutableMediaAsset = {
4
+ id: string;
5
+ fileName: string;
6
+ originalUrl?: string;
7
+ processingStatus: string;
8
+ activeVideoKnowledgeRevisionId?: string;
9
+ type: string;
10
+ documentIds: string[];
11
+ };
12
+
13
+ /** Select a title only when it has a clear lexical lead over every other source. */
14
+ export function clearlyTitleMatchedMediaAsset<T extends RoutableMediaAsset>(
15
+ query: string,
16
+ assets: readonly T[],
17
+ ): T | undefined {
18
+ const matchers = buildQueryTermMatchers(query);
19
+ if (matchers.length === 0) return undefined;
20
+ const ranked = assets
21
+ .map((asset) => ({ asset, matches: countMatchesIn(matchers, asset.fileName) }))
22
+ .filter(({ matches }) => matches >= Math.min(2, matchers.length))
23
+ .sort((left, right) => right.matches - left.matches);
24
+ if (ranked.length === 0) return undefined;
25
+ if (ranked.length > 1 && ranked[0].matches === ranked[1].matches) return undefined;
26
+ return ranked[0].asset;
27
+ }
28
+
29
+ /**
30
+ * Before ordinary retrieval runs, media may become authoritative only when
31
+ * the question explicitly names it. A sole video is a safe fallback after a
32
+ * retrieval miss, but it must never pre-empt a document or tabular hit.
33
+ */
34
+ export function explicitlySelectedMediaAsset<T extends RoutableMediaAsset>(
35
+ query: string,
36
+ assets: readonly T[],
37
+ ): T | undefined {
38
+ return clearlyTitleMatchedMediaAsset(query, assets);
39
+ }
40
+
41
+ /**
42
+ * Detect an explicitly named source that exists but is outside the Assistant
43
+ * source scope. A generic single-source fallback must not silently replace it
44
+ * with an unrelated enabled recording.
45
+ */
46
+ export function clearlyUnavailableTitleMatchedMediaAsset<T extends RoutableMediaAsset>(
47
+ query: string,
48
+ availableAssets: readonly T[],
49
+ allAssets: readonly T[],
50
+ ): T | undefined {
51
+ const matched = clearlyTitleMatchedMediaAsset(query, allAssets);
52
+ if (!matched) return undefined;
53
+ return availableAssets.some((asset) => asset.id === matched.id) ? undefined : matched;
54
+ }
55
+
56
+ /**
57
+ * A conversation carries a source selection as structured state. Once a
58
+ * source is selected, do not reinterpret ordinary user prose in an attempt
59
+ * to switch it: that is language- and genre-dependent, and can silently send
60
+ * a precise question to a different recording. A source switch must be
61
+ * represented by a new selected source in the conversation.
62
+ */
63
+ export function shouldKeepActiveMediaSource<T extends RoutableMediaAsset>(
64
+ _query: string,
65
+ activeAsset: T,
66
+ availableAssets: readonly T[],
67
+ ) {
68
+ return availableAssets.some((asset) => asset.id === activeAsset.id);
69
+ }
70
+
71
+ /** A conversational follow-up stays on its active source and skips global reranking. */
72
+ export function activeMediaFollowUpResult(
73
+ query: string,
74
+ asset: RoutableMediaAsset,
75
+ indexedContext?: string,
76
+ ) {
77
+ const context = indexedContext?.trim();
78
+ return {
79
+ query,
80
+ hits: [
81
+ {
82
+ title: asset.fileName,
83
+ url: `/api/media/${encodeURIComponent(asset.id)}`,
84
+ score: 1,
85
+ text: context ?? '',
86
+ ...(context ? { context } : {}),
87
+ metadata: {
88
+ mediaAssetId: asset.id,
89
+ mediaType: asset.type,
90
+ fileName: asset.fileName,
91
+ },
92
+ },
93
+ ],
94
+ videoEvidence: {
95
+ mediaAssetId: asset.id,
96
+ fileName: asset.fileName,
97
+ retrievalFallback: 'active-conversation-media-source' as const,
98
+ },
99
+ };
100
+ }
@@ -0,0 +1,48 @@
1
+ type IncomingMessage = Record<string, unknown> & {
2
+ id?: unknown;
3
+ role?: unknown;
4
+ parts?: unknown;
5
+ content?: unknown;
6
+ };
7
+
8
+ /** Accept UI messages and the common curl-friendly `{ role, content }` shape. */
9
+ export function normalizeIncomingMessages(value: unknown) {
10
+ if (!Array.isArray(value)) return [];
11
+
12
+ return value.flatMap((candidate, index) => {
13
+ if (!candidate || typeof candidate !== 'object') return [];
14
+ const message = candidate as IncomingMessage;
15
+ if (!['user', 'assistant', 'system'].includes(String(message.role ?? ''))) return [];
16
+
17
+ const suppliedParts = Array.isArray(message.parts)
18
+ ? message.parts.filter(
19
+ (part): part is Record<string, unknown> =>
20
+ Boolean(part) && typeof part === 'object' && typeof (part as any).type === 'string',
21
+ )
22
+ : [];
23
+ const contentParts = Array.isArray(message.content)
24
+ ? message.content.filter(
25
+ (part): part is Record<string, unknown> =>
26
+ Boolean(part) && typeof part === 'object' && typeof (part as any).type === 'string',
27
+ )
28
+ : [];
29
+ const parts =
30
+ suppliedParts.length > 0
31
+ ? suppliedParts
32
+ : contentParts.length > 0
33
+ ? contentParts
34
+ : typeof message.content === 'string' && message.content.trim()
35
+ ? [{ type: 'text', text: message.content }]
36
+ : [];
37
+ if (parts.length === 0) return [];
38
+
39
+ return [
40
+ {
41
+ ...message,
42
+ id: typeof message.id === 'string' && message.id.trim() ? message.id : `incoming-${index}`,
43
+ role: message.role as 'user' | 'assistant' | 'system',
44
+ parts,
45
+ },
46
+ ];
47
+ });
48
+ }
@@ -0,0 +1,157 @@
1
+ import { createAnthropic } from '@ai-sdk/anthropic';
2
+ import { createCohere } from '@ai-sdk/cohere';
3
+ import { createDeepSeek } from '@ai-sdk/deepseek';
4
+ import { createGateway } from '@ai-sdk/gateway';
5
+ import { createGoogleGenerativeAI } from '@ai-sdk/google';
6
+ import { createMistral } from '@ai-sdk/mistral';
7
+ import { createOpenAI } from '@ai-sdk/openai';
8
+ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
9
+ import {
10
+ getDefaultChatModel,
11
+ getDefaultVisionModel,
12
+ normalizeNativeChatModelId,
13
+ toChatDescriptor,
14
+ } from '@larkup/core/chat-models/registry';
15
+ import type { GatewayModel } from '@larkup/core/models-cache';
16
+ import type { CustomModelConfig, RagConfig } from '@larkup/core/types';
17
+
18
+ const FALLBACK_CHAT_MODELS: Record<string, string> = {
19
+ openai: 'openai/gpt-4o-mini',
20
+ anthropic: 'anthropic/claude-3-5-sonnet-latest',
21
+ google: 'google/gemini-3.6-flash',
22
+ cohere: 'cohere/command-r-plus',
23
+ mistral: 'mistral/mistral-large-latest',
24
+ deepseek: 'deepseek/deepseek-chat',
25
+ };
26
+
27
+ export interface ResolvedChatModel {
28
+ provider: string;
29
+ modelId: string;
30
+ apiKey?: string;
31
+ }
32
+
33
+ export interface ResolveChatModelOptions {
34
+ requiredTag?: string;
35
+ }
36
+
37
+ /**
38
+ * Resolve an LLM from persisted settings. The configured provider is
39
+ * authoritative: a gateway key must never be passed to a model vendor SDK.
40
+ */
41
+ export function resolveConfiguredChatModel(
42
+ config: RagConfig,
43
+ availableModels: GatewayModel[] = [],
44
+ options: ResolveChatModelOptions = {},
45
+ ): ResolvedChatModel {
46
+ const provider = config.chatProvider || config.embeddingProvider || 'openai';
47
+ let modelId = config.chatModelId?.trim();
48
+
49
+ if (!modelId && provider === 'custom' && config.customChatModels?.[0]) {
50
+ modelId = `custom:${config.customChatModels[0].modelName}`;
51
+ }
52
+
53
+ if (!modelId && availableModels.length > 0) {
54
+ const requiredTag = options.requiredTag;
55
+ const taggedModels = requiredTag
56
+ ? availableModels.filter((model) => model.tags?.includes(requiredTag))
57
+ : availableModels;
58
+ const candidates = taggedModels.length > 0 ? taggedModels : availableModels;
59
+ modelId = getDefaultChatModel(candidates.map(toChatDescriptor), provider)?.id;
60
+ }
61
+
62
+ if (!modelId && provider === 'vercel_ai_gateway') {
63
+ throw new Error('No gateway model is available. Choose a chat model before processing media.');
64
+ }
65
+
66
+ modelId = normalizeNativeChatModelId(provider, modelId);
67
+ modelId ||= FALLBACK_CHAT_MODELS[provider] || FALLBACK_CHAT_MODELS.openai;
68
+
69
+ return {
70
+ provider,
71
+ modelId,
72
+ apiKey: config.chatApiKey || config.embeddingApiKey || undefined,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Resolve the workspace-wide vision-language model. Image-capable tools share
78
+ * this setting so they do not accidentally run on a text-only chat model.
79
+ */
80
+ export function resolveConfiguredVisionModel(
81
+ config: RagConfig,
82
+ availableModels: GatewayModel[] = [],
83
+ ): ResolvedChatModel {
84
+ const provider =
85
+ config.visionProvider || config.chatProvider || config.embeddingProvider || 'openai';
86
+ let modelId = config.visionModelId?.trim();
87
+ const customModels = config.customVisionModels ?? [];
88
+
89
+ if (!modelId && provider === 'custom' && customModels[0]) {
90
+ modelId = `custom:${customModels[0].modelName}`;
91
+ }
92
+
93
+ if (!modelId) {
94
+ modelId = getDefaultVisionModel(availableModels.map(toChatDescriptor), provider)?.id;
95
+ }
96
+
97
+ if (!modelId) {
98
+ throw new Error(
99
+ `No vision-capable model is available for ${provider}. Configure a Vision Model in Settings → AI Models.`,
100
+ );
101
+ }
102
+
103
+ const chatProvider = config.chatProvider || config.embeddingProvider;
104
+ const apiKey =
105
+ config.visionApiKey ||
106
+ (provider === chatProvider ? config.chatApiKey : undefined) ||
107
+ (provider === config.embeddingProvider ? config.embeddingApiKey : undefined);
108
+
109
+ return { provider, modelId: normalizeNativeChatModelId(provider, modelId)!, apiKey };
110
+ }
111
+
112
+ export function createChatModel(
113
+ provider: string,
114
+ modelId: string,
115
+ apiKey?: string,
116
+ customChatModels: CustomModelConfig[] = [],
117
+ ): ReturnType<ReturnType<typeof createOpenAI>> {
118
+ if (modelId.startsWith('custom:')) {
119
+ const customName = modelId.slice('custom:'.length);
120
+ const custom = customChatModels.find((candidate) => candidate.modelName === customName);
121
+ if (!custom) {
122
+ throw new Error(`Custom chat model "${customName}" is not configured.`);
123
+ }
124
+
125
+ const customProvider = createOpenAICompatible({
126
+ name: 'custom_chat_provider',
127
+ baseURL: custom.baseUrl,
128
+ apiKey: custom.apiKey || apiKey || undefined,
129
+ });
130
+ return customProvider(custom.modelName);
131
+ }
132
+
133
+ const modelName = modelId.includes('/') ? modelId.split('/').slice(1).join('/') : modelId;
134
+
135
+ switch (provider) {
136
+ case 'google':
137
+ return createGoogleGenerativeAI({ apiKey })(modelName);
138
+ case 'cohere':
139
+ return createCohere({ apiKey })(modelName);
140
+ case 'mistral':
141
+ return createMistral({ apiKey })(modelName);
142
+ case 'deepseek':
143
+ return createDeepSeek({ apiKey })(modelName);
144
+ case 'anthropic':
145
+ return createAnthropic({ apiKey })(modelName);
146
+ case 'openai':
147
+ return createOpenAI({ apiKey })(modelName);
148
+ case 'vercel_ai_gateway':
149
+ // Gateway model IDs retain their vendor prefix (for example,
150
+ // "openai/gpt-4o-mini") because routing happens at the gateway.
151
+ return createGateway({ apiKey })(modelId);
152
+ case 'custom':
153
+ throw new Error('Choose a configured custom chat model before processing media.');
154
+ default:
155
+ throw new Error(`Unsupported chat provider "${provider}".`);
156
+ }
157
+ }
@@ -0,0 +1,3 @@
1
+ export const PERSONALIZED_RESPONSE_STYLE = `
2
+ Speak as a personalized assistant that already knows the user's context. Never mention or imply documents, files, stored data, a knowledge base, search, retrieval, sources, indexing, or tool calls. Do not start with phrases such as "Based on", "According to", "I found", "Your document says", or "The search results show". Start with the answer naturally and directly.
3
+ `;
@@ -0,0 +1,126 @@
1
+ const STOP_WORDS = new Set([
2
+ 'a',
3
+ 'an',
4
+ 'and',
5
+ 'are',
6
+ 'be',
7
+ 'by',
8
+ 'do',
9
+ 'for',
10
+ 'from',
11
+ 'how',
12
+ 'i',
13
+ 'in',
14
+ 'is',
15
+ 'it',
16
+ 'list',
17
+ 'me',
18
+ 'of',
19
+ 'on',
20
+ 'or',
21
+ 'show',
22
+ 'tell',
23
+ 'that',
24
+ 'the',
25
+ 'this',
26
+ 'to',
27
+ 'under',
28
+ 'was',
29
+ 'what',
30
+ 'when',
31
+ 'which',
32
+ 'who',
33
+ 'with',
34
+ ]);
35
+
36
+ export function searchTerms(text: string): string[] {
37
+ return [
38
+ ...new Set(
39
+ text
40
+ .toLocaleLowerCase()
41
+ .match(/[\p{L}\p{N}_-]+/gu)
42
+ ?.filter((term) => term.length > 1 && !STOP_WORDS.has(term)) ?? [],
43
+ ),
44
+ ];
45
+ }
46
+
47
+ // A plain substring check turns a short query term into a false hit inside
48
+ // any longer word that happens to contain it as a fragment -- "day" inside
49
+ // "Tuesday", "color" inside "Colored" -- which systematically favors long,
50
+ // generic documents over a short, genuinely on-topic one. Word-boundary
51
+ // matching (Unicode-aware, so non-Latin scripts aren't broken) keeps the
52
+ // coverage signal tied to the term actually appearing as its own word --
53
+ // tolerating a trailing plural/possessive suffix (view/views, routine/
54
+ // routines) so a strict boundary doesn't overcorrect into missing ordinary
55
+ // inflection, which "day"/"color" above are not instances of.
56
+ function termMatcher(term: string): RegExp {
57
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
58
+ return new RegExp(`(?<![\\p{L}\\p{N}])${escaped}(?:'s|es|s)?(?![\\p{L}\\p{N}])`, 'u');
59
+ }
60
+
61
+ export interface QueryTermMatcher {
62
+ term: string;
63
+ regex: RegExp;
64
+ }
65
+
66
+ /**
67
+ * Extracts a query's significant terms and compiles their matchers once, so
68
+ * a caller scanning many candidates (e.g. every document's title) doesn't
69
+ * redo term extraction and regex compilation on every single one of them.
70
+ */
71
+ export function buildQueryTermMatchers(query: string): QueryTermMatcher[] {
72
+ return searchTerms(query).map((term) => ({ term, regex: termMatcher(term) }));
73
+ }
74
+
75
+ /** How many of a precomputed set of query-term matchers appear (word-boundary
76
+ * aware) in `text`. */
77
+ export function countMatchesIn(matchers: QueryTermMatcher[], text: string): number {
78
+ if (matchers.length === 0) return 0;
79
+ const normalized = text.toLocaleLowerCase();
80
+ return matchers.filter(({ regex }) => regex.test(normalized)).length;
81
+ }
82
+
83
+ /**
84
+ * How many of the query's significant terms appear (word-boundary aware) in
85
+ * `title`. Exposed so a candidate-pool builder can pull in a document whose
86
+ * title is an obvious match for the query even when pure vector similarity
87
+ * placed it outside the retrieved pool entirely -- reranking can only
88
+ * reorder what's already in the pool, it can't recover a document that
89
+ * never made it in. Scanning many titles for the same query should use
90
+ * `buildQueryTermMatchers` once and call `countMatchesIn` per title instead
91
+ * of this convenience wrapper, which redoes both on every call.
92
+ */
93
+ export function countTitleTermMatches(query: string, title: string): number {
94
+ return countMatchesIn(buildQueryTermMatchers(query), title);
95
+ }
96
+
97
+ /** Merge semantic recall with exact terminology from names, labels, and IDs. */
98
+ export function rankKnowledgeHits<T extends { text?: string; title?: string; metadata?: any }>(
99
+ query: string,
100
+ hits: T[],
101
+ ): T[] {
102
+ const matchers = buildQueryTermMatchers(query);
103
+ if (matchers.length === 0 || hits.length < 2) return hits;
104
+
105
+ const phrase = query.trim().toLocaleLowerCase().replace(/\s+/g, ' ');
106
+ return hits
107
+ .map((hit, denseRank) => {
108
+ const searchable = [
109
+ hit.title,
110
+ hit.text,
111
+ hit.metadata?.description,
112
+ hit.metadata?.originalFile,
113
+ ]
114
+ .filter((value): value is string => typeof value === 'string')
115
+ .join(' ')
116
+ .toLocaleLowerCase();
117
+ const coverage = countMatchesIn(matchers, searchable) / matchers.length;
118
+ const title = hit.title?.toLocaleLowerCase();
119
+ const titleMatches = title ? countMatchesIn(matchers, title) : 0;
120
+ const phraseBonus = phrase.length > 3 && searchable.includes(phrase) ? 0.2 : 0;
121
+ const score = 0.35 / (denseRank + 1) + coverage + titleMatches * 0.08 + phraseBonus;
122
+ return { hit, score, denseRank };
123
+ })
124
+ .sort((left, right) => right.score - left.score || left.denseRank - right.denseRank)
125
+ .map(({ hit }) => hit);
126
+ }
@@ -0,0 +1,221 @@
1
+ function isMatchResultQuestion(text: string): boolean {
2
+ return (
3
+ /\b(?:who\s+won|winner|final\s+score|scoreline|match\s+result|what(?:'s|\s+is|\s+was)?\s+the\s+score)\b[\s\S]{0,120}\b(?:match|game|fixture|final|vs?\.?|versus)\b/i.test(
4
+ text,
5
+ ) ||
6
+ /\b(?:match|game|fixture|final|vs?\.?|versus)\b[\s\S]{0,120}\b(?:who\s+won|winner|final\s+score|scoreline|result|score)\b/i.test(
7
+ text,
8
+ )
9
+ );
10
+ }
11
+
12
+ // Up to two words (an adjective, a participle, a compound modifier) between
13
+ // a determiner/possessive and the noun it governs -- "my test video", "this
14
+ // uploaded recording", "the indexed sales report". A bare `\s+` between the
15
+ // two required a direct adjacency that real phrasing rarely uses.
16
+ const GAP = '(?:\\s+\\w+){0,2}\\s+';
17
+ const MY_FAVORITE_RE = new RegExp(
18
+ `\\b(my|our)${GAP}(favo(?:u)?rite|preference|choice|answer|name|result|score|winner)\\b`,
19
+ 'i',
20
+ );
21
+ const THIS_VIDEO_RE = new RegExp(
22
+ `\\b(?:this|that|the)${GAP}(?:match|video|episode|recording|diagram|chart|image|page|section)\\b`,
23
+ 'i',
24
+ );
25
+ const MY_UPLOAD_RE = new RegExp(
26
+ `\\b(my|our)${GAP}(document|file|data|image|picture|diagram|video|audio|upload|corpus|knowledge|database|db|pdf|report|spreadsheet|presentation)\\b`,
27
+ 'i',
28
+ );
29
+ const THE_DOCUMENT_RE = new RegExp(
30
+ `\\b(the|this|that)${GAP}(document|file|diagram|image|picture|upload|pdf|report)\\b`,
31
+ 'i',
32
+ );
33
+
34
+ export function requiresKnowledgeBaseSearch(text: string): boolean {
35
+ const normalized = text.trim();
36
+ if (/^(hi|hello|hey|thanks|thank you|ok|sure|yes|no|please|help)\b/i.test(normalized)) {
37
+ return false;
38
+ }
39
+
40
+ return (
41
+ MY_FAVORITE_RE.test(normalized) ||
42
+ /\b(?:what|which|who|where|when|how)\b[\s\S]{0,80}\b(my|our)\b/i.test(normalized) ||
43
+ THIS_VIDEO_RE.test(normalized) ||
44
+ /\b(?:shown|listed|named|counted|under)\b[\s\S]{0,80}\b(?:image|diagram|chart|pdf|document|page|section|resources)\b/i.test(
45
+ normalized,
46
+ ) ||
47
+ MY_UPLOAD_RE.test(normalized) ||
48
+ /\b(?:search|check|query)\s+(?:the\s+)?(?:knowledge base|corpus|database|db)\b/i.test(
49
+ normalized,
50
+ ) ||
51
+ THE_DOCUMENT_RE.test(normalized) ||
52
+ // A named match is commonly a question about an indexed recording. The
53
+ // user does not need to repeat "my video" for every follow-up question.
54
+ isMatchResultQuestion(normalized) ||
55
+ /\b(uploaded?|indexed|scraped|knowledge base|corpus)\b/i.test(normalized) ||
56
+ /\bshow\s+me\b/i.test(normalized)
57
+ );
58
+ }
59
+
60
+ type ChatMessageWithToolParts = {
61
+ role?: string;
62
+ parts?: unknown;
63
+ toolInvocations?: unknown;
64
+ };
65
+
66
+ function resultHasHits(result: unknown): boolean {
67
+ if (typeof result === 'string') {
68
+ try {
69
+ return resultHasHits(JSON.parse(result));
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+ if (
75
+ typeof result === 'object' &&
76
+ result !== null &&
77
+ (result as { type?: string }).type === 'json' &&
78
+ 'value' in result
79
+ ) {
80
+ return resultHasHits((result as { value?: unknown }).value);
81
+ }
82
+ return (
83
+ typeof result === 'object' &&
84
+ result !== null &&
85
+ Array.isArray((result as { hits?: unknown }).hits) &&
86
+ (result as { hits: unknown[] }).hits.length > 0
87
+ );
88
+ }
89
+
90
+ /**
91
+ * A follow-up may safely reuse evidence that is still in the conversation.
92
+ * Do not reuse an empty search: a new, more specific wording can surface a
93
+ * relevant chunk on the next attempt.
94
+ */
95
+ export function hasPriorKnowledgeBaseEvidence(messages: ChatMessageWithToolParts[]): boolean {
96
+ return messages.some((message) => {
97
+ if (message.role !== 'assistant') return false;
98
+ const parts = Array.isArray(message.parts) ? message.parts : [];
99
+ const toolPartsContainHits = parts.some((part: any) => {
100
+ if (part?.type === 'tool-invocation') {
101
+ return (
102
+ part.toolInvocation?.toolName === 'searchKnowledgeBase' &&
103
+ resultHasHits(part.toolInvocation?.result)
104
+ );
105
+ }
106
+ return part?.type === 'tool-searchKnowledgeBase' && resultHasHits(part.output ?? part.result);
107
+ });
108
+ if (toolPartsContainHits) return true;
109
+
110
+ const invocations = Array.isArray(message.toolInvocations) ? message.toolInvocations : [];
111
+ return invocations.some(
112
+ (invocation: any) =>
113
+ invocation?.toolName === 'searchKnowledgeBase' && resultHasHits(invocation?.result),
114
+ );
115
+ });
116
+ }
117
+
118
+ function extractMessageText(message: ChatMessageWithToolParts): string {
119
+ const anyM = message as any;
120
+ if (typeof anyM.content === 'string') return anyM.content;
121
+ const parts = Array.isArray(anyM.parts)
122
+ ? anyM.parts
123
+ : Array.isArray(anyM.content)
124
+ ? anyM.content
125
+ : [];
126
+ return parts
127
+ .filter((p: any) => p.type === 'text' && typeof p.text === 'string')
128
+ .map((p: any) => p.text)
129
+ .join(' ');
130
+ }
131
+
132
+ /**
133
+ * Keep retrieval efficient for natural continuations such as "what about it?"
134
+ * while treating every new standalone question as a fresh lookup.
135
+ */
136
+ export function isLikelyKnowledgeFollowUp(
137
+ text: string,
138
+ messages?: ChatMessageWithToolParts[],
139
+ ): boolean {
140
+ const normalized = text.trim().toLowerCase();
141
+ if (!normalized) return false;
142
+
143
+ const hasFollowUpKeywords =
144
+ /^(?:and|also|then|so)\b/.test(normalized) ||
145
+ /^(?:tell me more|continue|go on|can you (?:explain|elaborate|clarify))\b/.test(normalized) ||
146
+ /\b(?:it|this|that|they|them|those|these|its|their)\b/.test(normalized);
147
+
148
+ if (hasFollowUpKeywords) return true;
149
+
150
+ if (messages && messages.length > 0) {
151
+ const userMessages = messages.filter((m) => m.role === 'user');
152
+ if (userMessages.length >= 2) {
153
+ const prevUserText = extractMessageText(userMessages[userMessages.length - 2]);
154
+ if (prevUserText.trim().toLowerCase() === normalized) {
155
+ return true;
156
+ }
157
+ }
158
+ }
159
+
160
+ return false;
161
+ }
162
+
163
+ export function canReuseKnowledgeBaseEvidence(
164
+ text: string,
165
+ messages: ChatMessageWithToolParts[],
166
+ ): boolean {
167
+ return isLikelyKnowledgeFollowUp(text, messages) && hasPriorKnowledgeBaseEvidence(messages);
168
+ }
169
+
170
+ /** The chat workspace is intentionally a retrieval-only experience. */
171
+ export function requiresCurrentWebSearch(text: string): boolean {
172
+ void text;
173
+ return false;
174
+ }
175
+
176
+ /** Only the first retrieval is forced. The other source remains available for
177
+ * exactly one recovery attempt; subsequent steps are reserved for analysis and
178
+ * the final answer. */
179
+ export function retrievalToolsForStep<ToolName extends string>(options: {
180
+ stepNumber: number;
181
+ forceKnowledgeBaseSearch: boolean;
182
+ forceWebSearch: boolean;
183
+ toolNames: readonly ToolName[];
184
+ /** Reserve this and every later step for the model's final answer. */
185
+ finalAnswerStep?: number;
186
+ }):
187
+ | {
188
+ toolChoice?: { type: 'tool'; toolName: ToolName } | 'none';
189
+ activeTools?: ToolName[];
190
+ }
191
+ | undefined {
192
+ const { stepNumber, forceKnowledgeBaseSearch, forceWebSearch, toolNames, finalAnswerStep } =
193
+ options;
194
+ const without = (...blocked: string[]) => toolNames.filter((name) => !blocked.includes(name));
195
+
196
+ if (stepNumber === 0 && forceKnowledgeBaseSearch) {
197
+ return {
198
+ toolChoice: { type: 'tool', toolName: 'searchKnowledgeBase' as ToolName },
199
+ activeTools: ['searchKnowledgeBase' as ToolName],
200
+ };
201
+ }
202
+ if (stepNumber === 0 && forceWebSearch) return { activeTools: without('webSearch') };
203
+ if (stepNumber === 0) {
204
+ // If not forced, do not expose searchKnowledgeBase for ordinary conversation.
205
+ return { activeTools: without('webSearch', 'searchKnowledgeBase') };
206
+ }
207
+ if (forceKnowledgeBaseSearch) {
208
+ if (finalAnswerStep !== undefined && stepNumber >= finalAnswerStep) {
209
+ // A stale toolChoice of 'auto' left over from the top-level streamText
210
+ // config paired with zero active tools produced a degenerate
211
+ // near-empty completion (observed live: 1 output token, no text) --
212
+ // be explicit that no tool call is available for this step.
213
+ return { toolChoice: 'none', activeTools: [] };
214
+ }
215
+ return { activeTools: without('searchKnowledgeBase', 'webSearch') };
216
+ }
217
+ if (stepNumber === 1 && forceWebSearch) {
218
+ return { activeTools: without('webSearch') };
219
+ }
220
+ return { activeTools: without('webSearch', 'searchKnowledgeBase') };
221
+ }