multimodal-section.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. import { useTranslation } from "react-i18next"
  2. import { Input } from "@/components/ui/input"
  3. import { Label } from "@/components/ui/label"
  4. import type { SettingsDraft, DraftSetter } from "../settings-types"
  5. interface Props {
  6. draft: SettingsDraft
  7. setDraft: DraftSetter
  8. }
  9. const PROVIDER_OPTIONS: Array<{ value: SettingsDraft["multimodalProvider"]; label: string }> = [
  10. { value: "custom", label: "Custom (OpenAI-compat)" },
  11. { value: "openai", label: "OpenAI" },
  12. { value: "anthropic", label: "Anthropic" },
  13. { value: "google", label: "Google (Gemini)" },
  14. { value: "ollama", label: "Ollama" },
  15. ]
  16. export function MultimodalSection({ draft, setDraft }: Props) {
  17. const { t } = useTranslation()
  18. return (
  19. <div className="space-y-6">
  20. <div>
  21. <h2 className="text-xl font-semibold">{t("settings.sections.multimodal.title", "Image captioning")}</h2>
  22. <p className="mt-1 text-sm text-muted-foreground">
  23. {t(
  24. "settings.sections.multimodal.description",
  25. "Generate factual captions for images extracted from DOCX / PPTX during ingest. Captions are inserted as alt text inside the source markdown — they're what semantic search matches when you search for image content. Cached by image hash so duplicate logos / charts only call the LLM once.",
  26. )}
  27. </p>
  28. </div>
  29. {/* Master toggle. Off by default — captioning is a non-trivial
  30. token spend (one VLM call per image), and silently turning
  31. it on for every user the first time they import a document
  32. would surprise the budget.
  33. Note: the toggle row deliberately uses a 2-tier border +
  34. a textual ON/OFF state next to the pill switch. An earlier
  35. version had only the small pill and several users missed
  36. it entirely — pills are subtle when surrounded by long
  37. help text. The textual ON/OFF and the matching colored
  38. ring make the current state unambiguous at a glance. */}
  39. <div
  40. className={`flex items-center justify-between rounded-md border-2 p-3 transition-colors ${
  41. draft.multimodalEnabled
  42. ? "border-primary/40 bg-primary/5"
  43. : "border-border bg-background"
  44. }`}
  45. >
  46. <div className="min-w-0 flex-1">
  47. <div className="text-sm font-medium">
  48. {t("settings.sections.multimodal.enableLabel", "Enable captioning at ingest")}
  49. </div>
  50. <div className="text-xs text-muted-foreground">
  51. {t(
  52. "settings.sections.multimodal.enableHint",
  53. "Off: images still get extracted, just no captions. Search won't find them by visual content. On: each new image triggers one vision-LLM call (cached by hash).",
  54. )}
  55. </div>
  56. </div>
  57. <button
  58. type="button"
  59. onClick={() => setDraft("multimodalEnabled", !draft.multimodalEnabled)}
  60. role="switch"
  61. aria-checked={draft.multimodalEnabled}
  62. aria-label={t("settings.sections.multimodal.enableLabel", "Enable captioning at ingest")}
  63. className="ml-3 flex shrink-0 items-center gap-2"
  64. >
  65. <span
  66. className={`text-xs font-semibold ${
  67. draft.multimodalEnabled ? "text-primary" : "text-muted-foreground"
  68. }`}
  69. >
  70. {draft.multimodalEnabled
  71. ? t("settings.sections.multimodal.stateOn", "ON")
  72. : t("settings.sections.multimodal.stateOff", "OFF")}
  73. </span>
  74. <span
  75. className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
  76. draft.multimodalEnabled ? "bg-primary" : "bg-muted"
  77. }`}
  78. >
  79. <span
  80. className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
  81. draft.multimodalEnabled ? "translate-x-4.5" : "translate-x-0.5"
  82. }`}
  83. />
  84. </span>
  85. </button>
  86. </div>
  87. {draft.multimodalEnabled && (
  88. <>
  89. {/* "Use main LLM" toggle. Lets users with a single VL-capable
  90. model in their main config save the trouble of typing
  91. everything twice. The dedicated fields below show only
  92. when this is OFF.
  93. Layout: the text column needs `min-w-0 flex-1` so a
  94. long help text wraps inside its column instead of
  95. shoving the toggle off to the right (or worse,
  96. forcing the row's intrinsic width past the parent).
  97. Without this the row visibly broke in English where
  98. the multi-clause hint sentence is much longer than
  99. the equivalent CJK text. The toggle gets `shrink-0`
  100. for the symmetric reason — never lose the toggle to
  101. the text column. */}
  102. <div className="flex items-center justify-between gap-3 rounded-md border p-3">
  103. <div className="min-w-0 flex-1">
  104. <div className="text-sm font-medium">
  105. {t("settings.sections.multimodal.useMainLabel", "Use main LLM for captions")}
  106. </div>
  107. <div className="text-xs text-muted-foreground">
  108. {t(
  109. "settings.sections.multimodal.useMainHint",
  110. "Reuse the model picked under Settings → LLM provider. Only enable this if that model accepts image input — text-only models will return a 400.",
  111. )}
  112. </div>
  113. </div>
  114. <button
  115. type="button"
  116. onClick={() => setDraft("multimodalUseMainLlm", !draft.multimodalUseMainLlm)}
  117. role="switch"
  118. aria-checked={draft.multimodalUseMainLlm}
  119. aria-label={t(
  120. "settings.sections.multimodal.useMainLabel",
  121. "Use main LLM for captions",
  122. )}
  123. className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
  124. draft.multimodalUseMainLlm ? "bg-primary" : "bg-muted"
  125. }`}
  126. >
  127. <span
  128. className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
  129. draft.multimodalUseMainLlm ? "translate-x-4.5" : "translate-x-0.5"
  130. }`}
  131. />
  132. </button>
  133. </div>
  134. {!draft.multimodalUseMainLlm && (
  135. <div className="space-y-4 rounded-md border p-3">
  136. <div className="text-sm font-medium">
  137. {t("settings.sections.multimodal.dedicatedHeading", "Dedicated vision endpoint")}
  138. </div>
  139. <div className="space-y-2">
  140. <Label>{t("settings.sections.multimodal.provider", "Provider")}</Label>
  141. <select
  142. className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
  143. value={draft.multimodalProvider}
  144. onChange={(e) =>
  145. setDraft("multimodalProvider", e.target.value as SettingsDraft["multimodalProvider"])
  146. }
  147. >
  148. {PROVIDER_OPTIONS.map((p) => (
  149. <option key={p.value} value={p.value}>
  150. {p.label}
  151. </option>
  152. ))}
  153. </select>
  154. </div>
  155. {draft.multimodalProvider === "ollama" && (
  156. <div className="space-y-2">
  157. <Label>{t("settings.sections.multimodal.ollamaUrl", "Ollama URL")}</Label>
  158. <Input
  159. value={draft.multimodalOllamaUrl}
  160. onChange={(e) => setDraft("multimodalOllamaUrl", e.target.value)}
  161. placeholder="http://localhost:11434"
  162. />
  163. </div>
  164. )}
  165. {draft.multimodalProvider === "custom" && (
  166. <div className="space-y-2">
  167. <Label>{t("settings.sections.multimodal.customEndpoint", "Endpoint URL")}</Label>
  168. <Input
  169. value={draft.multimodalCustomEndpoint}
  170. onChange={(e) => setDraft("multimodalCustomEndpoint", e.target.value)}
  171. placeholder="http://localhost:1234/v1"
  172. />
  173. <p className="text-xs text-muted-foreground">
  174. {t(
  175. "settings.sections.multimodal.customEndpointHint",
  176. "OpenAI-compatible /v1 base. LM Studio, llama.cpp server, vLLM, LocalAI all work.",
  177. )}
  178. </p>
  179. </div>
  180. )}
  181. <div className="space-y-2">
  182. <Label>{t("settings.sections.multimodal.apiKey", "API key")}</Label>
  183. <Input
  184. type="password"
  185. value={draft.multimodalApiKey}
  186. onChange={(e) => setDraft("multimodalApiKey", e.target.value)}
  187. placeholder={t(
  188. "settings.sections.multimodal.apiKeyPlaceholder",
  189. "Leave blank for local / no-auth endpoints",
  190. )}
  191. />
  192. </div>
  193. <div className="space-y-2">
  194. <Label>{t("settings.sections.multimodal.model", "Model")}</Label>
  195. <Input
  196. value={draft.multimodalModel}
  197. onChange={(e) => setDraft("multimodalModel", e.target.value)}
  198. placeholder="e.g. Qwen2.5-VL-7B-Instruct, claude-3-5-sonnet-latest, gemini-2.5-flash"
  199. />
  200. <p className="text-xs text-muted-foreground">
  201. {t(
  202. "settings.sections.multimodal.modelHint",
  203. "Must be a vision-capable model. Text-only models will fail with a 400 / image-not-supported error at first ingest.",
  204. )}
  205. </p>
  206. </div>
  207. </div>
  208. )}
  209. {/* Concurrency knob — practical impact: a 30-image PPTX at
  210. concurrency=1 with a 10s/image VLM is 5 minutes of
  211. ingest wall time; concurrency=4 makes it ~75s. Going
  212. wider than ~8 is rarely a win on a single-GPU server
  213. that batches under the hood anyway. */}
  214. <div className="space-y-2 rounded-md border p-3">
  215. <Label>{t("settings.sections.multimodal.concurrency", "Concurrent caption requests")}</Label>
  216. <Input
  217. type="number"
  218. min={1}
  219. max={16}
  220. step={1}
  221. value={draft.multimodalConcurrency}
  222. onChange={(e) => {
  223. const n = Number(e.target.value)
  224. setDraft("multimodalConcurrency", Number.isFinite(n) ? n : 4)
  225. }}
  226. />
  227. <p className="text-xs text-muted-foreground">
  228. {t(
  229. "settings.sections.multimodal.concurrencyHint",
  230. "How many caption requests run in parallel. 1 = strictly sequential. 4 is a good default for most setups; raise to 8+ only on a beefy GPU or hosted endpoint.",
  231. )}
  232. </p>
  233. </div>
  234. {/* Cost guardrail panel — mostly informational for now. */}
  235. <div className="space-y-1 rounded-md border border-amber-500/40 bg-amber-500/5 p-3">
  236. <div className="text-sm font-medium text-amber-700 dark:text-amber-400">
  237. {t("settings.sections.multimodal.costHeading", "Cost guardrails")}
  238. </div>
  239. <ul className="ml-4 list-disc space-y-1 text-xs text-muted-foreground">
  240. <li>
  241. {t(
  242. "settings.sections.multimodal.costPoint1",
  243. "Each new image triggers one vision-LLM call (~500-2000 tokens depending on model + thinking mode).",
  244. )}
  245. </li>
  246. <li>
  247. {t(
  248. "settings.sections.multimodal.costPoint2",
  249. "Captions are cached by SHA-256 of image bytes. Duplicate logos / shared chart templates across documents only ever incur ONE call.",
  250. )}
  251. </li>
  252. <li>
  253. {t(
  254. "settings.sections.multimodal.costPoint3",
  255. "Rust-side filter drops images smaller than 100×100 px and caps at 500 images per source — pathological documents can't blow up the bill.",
  256. )}
  257. </li>
  258. <li>
  259. {t(
  260. "settings.sections.multimodal.costPoint4",
  261. "Prefer a local vision model (Qwen2.5-VL via LM Studio / Ollama) for bulk ingestion; reserve hosted vision APIs for one-off high-stakes documents.",
  262. )}
  263. </li>
  264. </ul>
  265. </div>
  266. </>
  267. )}
  268. </div>
  269. )
  270. }