Merge branch 'upstream' into concedo_experimental

# Conflicts:
#	.github/workflows/server-self-hosted.yml
#	CMakeLists.txt
#	CODEOWNERS
#	ci/run.sh
#	cmake/llama-config.cmake.in
#	common/chat.cpp
#	examples/sycl/start-svr.sh
#	examples/sycl/test.sh
#	examples/sycl/win-start-svr.bat
#	examples/sycl/win-test.bat
#	ggml/src/ggml-sycl/ggml-sycl.cpp
#	ggml/src/ggml-sycl/vecdotq.hpp
#	ggml/src/ggml-vulkan/CMakeLists.txt
#	scripts/wc2wt.sh
#	tests/test-backend-ops.cpp
#	tests/test-chat.cpp
This commit is contained in:
Concedo
2026-05-18 21:27:23 +08:00
79 changed files with 946 additions and 305 deletions
+22 -85
View File
@@ -1032,23 +1032,33 @@ json oaicompat_chat_params_parse(
auto caps = common_chat_templates_get_caps(opt.tmpls.get());
common_chat_templates_inputs inputs;
inputs.messages = common_chat_msgs_parse_oaicompat(messages);
inputs.tools = common_chat_tools_parse_oaicompat(tools);
inputs.tool_choice = common_chat_tool_choice_parse_oaicompat(tool_choice);
inputs.json_schema = json_schema.is_null() ? "" : json_schema.dump();
inputs.grammar = grammar;
inputs.use_jinja = opt.use_jinja;
inputs.parallel_tool_calls = json_value(body, "parallel_tool_calls", caps["supports_parallel_tool_calls"]);
inputs.add_generation_prompt = json_value(body, "add_generation_prompt", true);
const bool continue_final_message = json_value(body, "continue_final_message", false);
if (continue_final_message && inputs.add_generation_prompt) {
inputs.messages = common_chat_msgs_parse_oaicompat(messages);
inputs.tools = common_chat_tools_parse_oaicompat(tools);
inputs.tool_choice = common_chat_tool_choice_parse_oaicompat(tool_choice);
inputs.json_schema = json_schema.is_null() ? "" : json_schema.dump();
inputs.grammar = grammar;
inputs.use_jinja = opt.use_jinja;
inputs.parallel_tool_calls = json_value(body, "parallel_tool_calls", caps["supports_parallel_tool_calls"]);
inputs.add_generation_prompt = json_value(body, "add_generation_prompt", true);
inputs.continue_final_message = body.contains("continue_final_message") ?
common_chat_continuation_parse(body.at("continue_final_message")) :
COMMON_CHAT_CONTINUATION_NONE;
if (inputs.continue_final_message == COMMON_CHAT_CONTINUATION_NONE && opt.prefill_assistant
&& !inputs.messages.empty() && inputs.messages.back().role == "assistant") {
if (inputs.messages.size() >= 2 && inputs.messages[inputs.messages.size() - 2].role == "assistant") {
throw std::invalid_argument("Cannot have 2 or more assistant messages at the end of the list.");
}
inputs.continue_final_message = COMMON_CHAT_CONTINUATION_AUTO;
inputs.add_generation_prompt = false;
}
if (inputs.continue_final_message != COMMON_CHAT_CONTINUATION_NONE && inputs.add_generation_prompt) {
throw std::invalid_argument("Cannot set both add_generation_prompt and continue_final_message to true.");
}
inputs.reasoning_format = opt.reasoning_format;
inputs.reasoning_format = opt.reasoning_format;
if (body.contains("reasoning_format")) {
inputs.reasoning_format = common_reasoning_format_from_name(body.at("reasoning_format").get<std::string>());
}
inputs.enable_thinking = opt.enable_thinking;
inputs.enable_thinking = opt.enable_thinking;
if (!inputs.tools.empty() && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE) {
if (body.contains("grammar")) {
throw std::invalid_argument("Cannot use custom grammar constraints with tools.");
@@ -1073,84 +1083,11 @@ json oaicompat_chat_params_parse(
throw std::invalid_argument("invalid type for \"enable_thinking\" (expected boolean, got string)");
}
// if the assistant message appears at the end of list, we do not add end-of-turn token
// for ex. this can be useful to modify the reasoning process in reasoning models
// continue_final_message is the explicit opt in alias from the vLLM/transformers API,
// equivalent to the prefill_assistant heuristic
bool prefill_assistant_message = !inputs.messages.empty() && inputs.messages.back().role == "assistant"
&& (continue_final_message || opt.prefill_assistant);
common_chat_msg last_message;
if (prefill_assistant_message) {
last_message = inputs.messages.back();
inputs.messages.pop_back();
/* sanity check, max one assistant message at the end of the list */
if (!inputs.messages.empty() && inputs.messages.back().role == "assistant"){
throw std::invalid_argument("Cannot have 2 or more assistant messages at the end of the list.");
}
// reject reasoning prefill on channel based templates that do not expose explicit thinking tags
if (!last_message.reasoning_content.empty() && inputs.enable_thinking) {
auto probe_params = common_chat_templates_apply(opt.tmpls.get(), inputs);
if (probe_params.supports_thinking && probe_params.thinking_end_tag.empty()) {
throw std::invalid_argument("Assistant prefill with reasoning_content is not supported yet for this template.");
}
}
inputs.add_generation_prompt = true;
}
inputs.force_pure_content = opt.force_pure_content;
// Apply chat template to the list of messages
auto chat_params = common_chat_templates_apply(opt.tmpls.get(), inputs);
/* Append assistant prefilled message */
if (prefill_assistant_message) {
const bool thinking_active = chat_params.supports_thinking && !chat_params.thinking_end_tag.empty();
const bool has_reasoning = !last_message.reasoning_content.empty();
const bool has_content = !last_message.content.empty() || !last_message.content_parts.empty();
const bool mid_reasoning = has_reasoning && !has_content;
// some templates inject thinking_start in generation_prompt, others let the model emit it
const bool gp_has_think = thinking_active
&& chat_params.generation_prompt.find(chat_params.thinking_start_tag) != std::string::npos;
// open the thinking block when reasoning is present and the template did not inject it
if (has_reasoning) {
if (thinking_active && !gp_has_think) {
chat_params.prompt += chat_params.thinking_start_tag;
}
chat_params.prompt += last_message.reasoning_content;
}
if (thinking_active) {
if (mid_reasoning) {
// model continues inside the thinking block, keep generation_prompt open on think
if (!gp_has_think) {
chat_params.generation_prompt += chat_params.thinking_start_tag;
}
} else {
// close thinking block when reasoning is followed by content, or when the template forced it open
if (has_reasoning || gp_has_think) {
chat_params.prompt += chat_params.thinking_end_tag;
}
// strip thinking_start from generation_prompt so the parser routes model output as content
auto pos = chat_params.generation_prompt.rfind(chat_params.thinking_start_tag);
if (pos != std::string::npos) {
chat_params.generation_prompt = chat_params.generation_prompt.substr(0, pos);
}
}
}
if (!last_message.content_parts.empty()) {
for (auto & p : last_message.content_parts) {
chat_params.prompt += p.text;
}
} else {
chat_params.prompt += last_message.content;
}
}
llama_params["chat_format"] = static_cast<int>(chat_params.format);
llama_params["prompt"] = chat_params.prompt;
if (!chat_params.grammar.empty()) {
+6 -1
View File
@@ -243,6 +243,11 @@ struct server_slot {
return task->need_embd() || (spec && common_speculative_need_embd(spec));
}
bool need_embd_pre_norm() const {
GGML_ASSERT(task);
return spec && common_speculative_need_embd_pre_norm(spec);
}
// if the context does not have a memory module then all embeddings have to be computed within a single ubatch
// also we cannot split if the pooling would require any past tokens
// (MTP supports splitting — uses task->need_embd() not need_embd())
@@ -4527,7 +4532,7 @@ std::unique_ptr<server_res_generator> server_routes::handle_embeddings_impl(cons
}
}
int embd_normalize = 2; // default to Euclidean/L2 norm
int embd_normalize = params.embd_normalize;
if (body.count("embd_normalize") != 0) {
embd_normalize = body.at("embd_normalize");
if (meta->pooling_type == LLAMA_POOLING_TYPE_NONE) {
+3 -5
View File
@@ -231,11 +231,10 @@ bool server_http_context::init(const common_params & params) {
};
auto middleware_server_state = [this](const httplib::Request & req, httplib::Response & res) {
(void)req; // suppress unused parameter warning when LLAMA_BUILD_UI / LLAMA_BUILD_WEBUI is not defined
(void)req; // suppress unused parameter warning when LLAMA_BUILD_UI is not defined
bool ready = is_ready.load();
if (!ready) {
// Support both old and new preprocessor defines
#if defined(LLAMA_BUILD_UI) || defined(LLAMA_BUILD_WEBUI)
#if defined(LLAMA_BUILD_UI)
auto tmp = string_split<std::string>(req.path, '.');
if (req.path == "/" || (tmp.size() > 0 && tmp.back() == "html")) {
res.status = 503;
@@ -313,8 +312,7 @@ bool server_http_context::init(const common_params & params) {
return 1;
}
} else {
// Support both old and new preprocessor defines
#if defined(LLAMA_BUILD_UI) || defined(LLAMA_BUILD_WEBUI)
#if defined(LLAMA_BUILD_UI)
// using embedded static index.html
srv->Get(params.api_prefix + "/", [](const httplib::Request & /*req*/, httplib::Response & res) {
// COEP and COOP headers, required by pyodide (python interpreter)
+3 -2
View File
@@ -798,9 +798,10 @@ void server_models::load(const std::string & name) {
std::thread log_thread([&]() {
// read stdout/stderr and forward to main server log
// also handle status report from child process
std::vector<char> vec_buf(128 * 1024); // large buffer for storing info
char * buffer = vec_buf.data();
if (stdout_file) {
char buffer[128 * 1024]; // large buffer for storing info
while (fgets(buffer, sizeof(buffer), stdout_file) != nullptr) {
while (fgets(buffer, vec_buf.size(), stdout_file) != nullptr) {
LOG("[%5d] %s", port, buffer);
std::string str(buffer);
if (string_starts_with(buffer, CMD_CHILD_TO_ROUTER_READY)) {
+12
View File
@@ -144,6 +144,17 @@ json task_params::to_json(bool only_metrics) const {
//
// task_result_state
//
task_result_state::task_result_state(const common_chat_parser_params & chat_parser_params)
: chat_parser_params(chat_parser_params)
, oai_resp_id("resp_" + random_string())
, oai_resp_reasoning_id("rs_" + random_string())
, oai_resp_message_id("msg_" + random_string()) {
if (!chat_parser_params.echo) {
// initialize chat_msg to avoid emitting a delta containing the assistant prefill
chat_msg = common_chat_parse("", true, chat_parser_params);
}
}
common_chat_msg task_result_state::update_chat_msg(
const std::string & text_added,
bool is_partial,
@@ -421,6 +432,7 @@ task_params server_task::params_from_json_cmpl(
if (data.contains("chat_parser")) {
params.chat_parser_params.parser.load(data.at("chat_parser").get<std::string>());
}
params.chat_parser_params.echo = json_value(data, "echo", false);
}
{
+1 -5
View File
@@ -112,11 +112,7 @@ struct task_result_state {
const std::string oai_resp_message_id;
std::string oai_resp_fc_id; // function call ID for current args delta
task_result_state(const common_chat_parser_params & chat_parser_params)
: chat_parser_params(chat_parser_params)
, oai_resp_id("resp_" + random_string())
, oai_resp_reasoning_id("rs_" + random_string())
, oai_resp_message_id("msg_" + random_string()) {}
task_result_state(const common_chat_parser_params & chat_parser_params);
// parse partial tool calls and update the internal state
common_chat_msg update_chat_msg(
+4 -2
View File
@@ -86,7 +86,10 @@ int main(int argc, char ** argv) {
llama_backend_init();
llama_numa_init(params.numa);
common_params_print_info(params);
// router server never loads a model and must not touch the GPU
// skip device enumeration so the CUDA primary context stays uncreated
const bool is_router_server = params.model.path.empty();
common_params_print_info(params, !is_router_server);
// validate batch size for embeddings
// embeddings require all tokens to be processed in a single ubatch
@@ -126,7 +129,6 @@ int main(int argc, char ** argv) {
server_routes routes(params, ctx_server);
server_tools tools;
bool is_router_server = params.model.path.empty();
std::optional<server_models_routes> models_routes{};
if (is_router_server) {
// setup server instances manager
@@ -158,11 +158,12 @@ def test_chat_template():
@pytest.mark.parametrize("prefill,re_prefill", [
("Whill", "Whill"),
([{"type": "text", "text": "Wh"}, {"type": "text", "text": "ill"}], "Whill"),
([{"type": "text", "text": "Wh"}, {"type": "text", "text": "ill"}], "Wh\n\nill"),
])
def test_chat_template_assistant_prefill(prefill, re_prefill):
global server
server.chat_template = "llama3"
server.jinja = True
server.chat_template_file = "../../../models/templates/meta-llama-Llama-3.1-8B-Instruct.jinja"
server.debug = True # to get the "__verbose" object in the response
server.start()
res = server.make_request("POST", "/chat/completions", data={
@@ -175,14 +176,15 @@ def test_chat_template_assistant_prefill(prefill, re_prefill):
})
assert res.status_code == 200
assert "__verbose" in res.body
assert res.body["__verbose"]["prompt"] == f"<s> <|start_header_id|>system<|end_header_id|>\n\nBook<|eot_id|><|start_header_id|>user<|end_header_id|>\n\nWhat is the best book<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n{re_prefill}"
assert res.body["__verbose"]["prompt"].endswith(f"<|start_header_id|>user<|end_header_id|>\n\nWhat is the best book<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n{re_prefill}")
def test_chat_template_continue_final_message_vllm_compat():
"""continue_final_message is the vLLM/transformers explicit alias for the prefill_assistant heuristic.
Both must produce the same prompt."""
global server
server.chat_template = "llama3"
server.jinja = True
server.chat_template_file = "../../../models/templates/meta-llama-Llama-3.1-8B-Instruct.jinja"
server.debug = True
server.start()
res = server.make_request("POST", "/chat/completions", data={
@@ -197,7 +199,7 @@ def test_chat_template_continue_final_message_vllm_compat():
})
assert res.status_code == 200
assert "__verbose" in res.body
assert res.body["__verbose"]["prompt"] == "<s> <|start_header_id|>system<|end_header_id|>\n\nBook<|eot_id|><|start_header_id|>user<|end_header_id|>\n\nWhat is the best book<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\nWhill"
assert res.body["__verbose"]["prompt"].endswith("<|start_header_id|>user<|end_header_id|>\n\nWhat is the best book<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\nWhill")
def test_chat_template_continue_final_message_mutual_exclusion():
+3 -10
View File
@@ -14,12 +14,7 @@ endif()
set(TARGET_SRCS "")
set(UI_COMPILE_DEFS "")
# Support both old (LLAMA_BUILD_WEBUI) and new (LLAMA_BUILD_UI) option names
if(LLAMA_BUILD_WEBUI OR LLAMA_BUILD_UI)
if(LLAMA_BUILD_WEBUI AND NOT LLAMA_BUILD_UI)
message(DEPRECATION "LLAMA_BUILD_WEBUI is deprecated, use LLAMA_BUILD_UI instead")
endif()
if(LLAMA_BUILD_UI)
set(PUBLIC_ASSETS
index.html
bundle.js
@@ -125,19 +120,17 @@ if(LLAMA_BUILD_WEBUI OR LLAMA_BUILD_UI)
endforeach()
list(APPEND UI_COMPILE_DEFS
LLAMA_BUILD_WEBUI # Deprecated: use LLAMA_BUILD_UI
LLAMA_BUILD_UI
LLAMA_WEBUI_DEFAULT_ENABLED=1 # Deprecated: use LLAMA_UI_DEFAULT_ENABLED
LLAMA_UI_DEFAULT_ENABLED=1
)
message(STATUS "UI: embedded with source: ${UI_SOURCE}")
else()
message(WARNING "UI: no source available. Neither local build (build/tools/ui/dist/) nor HF Bucket download succeeded.")
message(WARNING "UI: building server without embedded UI. Set LLAMA_BUILD_UI=OFF to suppress this warning.")
list(APPEND UI_COMPILE_DEFS LLAMA_WEBUI_DEFAULT_ENABLED=0 LLAMA_UI_DEFAULT_ENABLED=0)
list(APPEND UI_COMPILE_DEFS LLAMA_UI_DEFAULT_ENABLED=0)
endif()
else()
list(APPEND UI_COMPILE_DEFS LLAMA_WEBUI_DEFAULT_ENABLED=0 LLAMA_UI_DEFAULT_ENABLED=0)
list(APPEND UI_COMPILE_DEFS LLAMA_UI_DEFAULT_ENABLED=0)
endif()
# Build the static library
+8
View File
@@ -1,4 +1,5 @@
@import 'tailwindcss';
@source ".";
@import 'tw-animate-css';
@@ -39,6 +40,9 @@
--sidebar-ring: oklch(0.708 0 0);
--code-background: oklch(0.985 0 0);
--code-foreground: oklch(0.145 0 0);
--font-mono:
ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
'Liberation Mono', Menlo, monospace;
--layer-popover: 1000000;
--chat-form-area-height: 8rem;
@@ -171,6 +175,10 @@
*::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}
:where(code, pre, kbd, samp) {
font-family: var(--font-mono);
}
}
@layer utilities {
+2
View File
@@ -39,6 +39,7 @@ import type {
DatabaseMessage,
DatabaseMessageExtra,
DatabaseMessageExtraAudioFile,
DatabaseMessageExtraVideoFile,
DatabaseMessageExtraImageFile,
DatabaseMessageExtraTextFile,
DatabaseMessageExtraPdfFile,
@@ -102,6 +103,7 @@ declare global {
DatabaseMessage,
DatabaseMessageExtra,
DatabaseMessageExtraAudioFile,
DatabaseMessageExtraVideoFile,
DatabaseMessageExtraImageFile,
DatabaseMessageExtraTextFile,
DatabaseMessageExtraPdfFile,
@@ -1,5 +1,5 @@
<script lang="ts">
import { Eye, Mic } from '@lucide/svelte';
import { Eye, Mic, Video } from '@lucide/svelte';
import { ModelModality } from '$lib/enums';
interface Props {
@@ -11,7 +11,7 @@
</script>
{#each modalities as modality (modality)}
{#if modality === ModelModality.VISION || modality === ModelModality.AUDIO}
{#if modality === ModelModality.VISION || modality === ModelModality.AUDIO || modality === ModelModality.VIDEO}
<span
class={[
'inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium',
@@ -21,7 +21,11 @@
{#if modality === ModelModality.VISION}
<Eye class="h-3 w-3" />
Vision
Vision (Image)
{:else if modality === ModelModality.VIDEO}
<Video class="h-3 w-3" />
Vision (Video)
{:else}
<Mic class="h-3 w-3" />
@@ -1,10 +1,12 @@
<script lang="ts">
import { X } from '@lucide/svelte';
import { X, Music, Video } from '@lucide/svelte';
import {
formatFileSize,
getFileTypeLabel,
getPreviewText,
isPdfFile,
isAudioFile,
isVideoFile,
isTextFile
} from '$lib/utils';
import { ActionIcon } from '$lib/components/app';
@@ -38,6 +40,8 @@
}: Props = $props();
let isPdf = $derived(isPdfFile(attachment, uploadedFile));
let isAudio = $derived(isAudioFile(attachment, uploadedFile));
let isVideo = $derived(isVideoFile(attachment, uploadedFile));
let isPdfWithContent = $derived(isPdf && !!textContent);
let isText = $derived(isTextFile(attachment, uploadedFile));
@@ -102,7 +106,13 @@
<div
class="flex h-8 w-8 items-center justify-center rounded bg-primary/10 text-xs font-medium text-primary"
>
{fileTypeLabel}
{#if isAudio}
<Music class="h-4 w-4 text-white/70" />
{:else if isVideo}
<Video class="h-4 w-4 text-white/70" />
{:else}
{fileTypeLabel}
{/if}
</div>
{/snippet}
@@ -12,6 +12,7 @@
getAttachmentDisplayItems,
getLanguageFromFilename,
isAudioFile,
isVideoFile,
isImageFile,
isMcpPrompt,
isMcpResource,
@@ -29,6 +30,7 @@
textContent?: string;
isImage: boolean;
isAudio: boolean;
isVideo: boolean;
}
interface Props {
@@ -54,7 +56,8 @@
(item): PreviewItem => ({
...item,
isImage: isImageFile(item.attachment, item.uploadedFile),
isAudio: isAudioFile(item.attachment, item.uploadedFile)
isAudio: isAudioFile(item.attachment, item.uploadedFile),
isVideo: isVideoFile(item.attachment, item.uploadedFile)
})
)
);
@@ -102,6 +105,9 @@
let isAudio = $derived(
currentItem ? isAudioFile(currentItem.attachment, currentItem.uploadedFile) : false
);
let isVideo = $derived(
currentItem ? isVideoFile(currentItem.attachment, currentItem.uploadedFile) : false
);
let isImage = $derived(
currentItem ? isImageFile(currentItem.attachment, currentItem.uploadedFile) : false
);
@@ -148,6 +154,20 @@
: null
);
let videoSrc = $derived(
isVideo && currentItem
? (currentItem.uploadedFile?.preview ??
(currentItem.attachment &&
'mimeType' in currentItem.attachment &&
'base64Data' in currentItem.attachment
? createBase64DataUrl(
currentItem.attachment.mimeType,
currentItem.attachment.base64Data
)
: null))
: null
);
export function prev() {
currentIndex = currentIndex > 0 ? currentIndex - 1 : allItems.length - 1;
}
@@ -173,11 +193,13 @@
{currentItem}
{isImage}
{isAudio}
{isVideo}
{isPdf}
{isText}
{displayPreview}
{displayTextContent}
{audioSrc}
{videoSrc}
{language}
{hasVisionModality}
{activeModelId}
@@ -1,9 +1,10 @@
<script lang="ts">
import type { ChatAttachmentDisplayItem } from '$lib/types';
import { Image, Music, FileText, FileIcon } from '@lucide/svelte';
import { Image, Music, Video, FileText, FileIcon } from '@lucide/svelte';
import ChatAttachmentsPreviewCurrentItemPdf from './ChatAttachmentsPreviewCurrentItemPdf.svelte';
import ChatAttachmentsPreviewCurrentItemImage from './ChatAttachmentsPreviewCurrentItemImage.svelte';
import ChatAttachmentsPreviewCurrentItemAudio from './ChatAttachmentsPreviewCurrentItemAudio.svelte';
import ChatAttachmentsPreviewCurrentItemVideo from './ChatAttachmentsPreviewCurrentItemVideo.svelte';
import ChatAttachmentsPreviewCurrentItemText from './ChatAttachmentsPreviewCurrentItemText.svelte';
import ChatAttachmentsPreviewCurrentItemUnavailable from './ChatAttachmentsPreviewCurrentItemUnavailable.svelte';
@@ -11,11 +12,13 @@
currentItem: ChatAttachmentDisplayItem | null;
isImage: boolean;
isAudio: boolean;
isVideo: boolean;
isPdf: boolean;
isText: boolean;
displayPreview: string | undefined;
displayTextContent: string | undefined;
audioSrc: string | null;
videoSrc: string | null;
language: string;
hasVisionModality: boolean;
activeModelId?: string;
@@ -25,21 +28,25 @@
currentItem,
isImage,
isAudio,
isVideo,
isPdf,
isText,
displayPreview,
displayTextContent,
audioSrc,
videoSrc,
language,
hasVisionModality,
activeModelId
}: Props = $props();
let IconComponent = $derived(
isImage ? Image : isText || isPdf ? FileText : isAudio ? Music : FileIcon
isImage ? Image : isText || isPdf ? FileText : isAudio ? Music : isVideo ? Video : FileIcon
);
let isUnavailable = $derived(!isPdf && !isImage && !(isText && displayTextContent) && !isAudio);
let isUnavailable = $derived(
!isPdf && !isImage && !(isText && displayTextContent) && !isAudio && !isVideo
);
</script>
{#if currentItem}
@@ -58,6 +65,8 @@
<ChatAttachmentsPreviewCurrentItemText {displayTextContent} {language} />
{:else if isAudio}
<ChatAttachmentsPreviewCurrentItemAudio {currentItem} {audioSrc} />
{:else if isVideo}
<ChatAttachmentsPreviewCurrentItemVideo {currentItem} {videoSrc} />
{:else if isUnavailable}
<ChatAttachmentsPreviewCurrentItemUnavailable {IconComponent} />
{/if}
@@ -0,0 +1,26 @@
<script lang="ts">
import { Video } from '@lucide/svelte';
interface Props {
currentItem: { name?: string } | null;
videoSrc: string | null;
}
let { currentItem, videoSrc }: Props = $props();
</script>
<div class="flex flex-1 items-center justify-center p-8">
<div class="w-full max-w-md text-center">
<Video class="mx-auto mb-4 h-16 w-16 text-white/50" />
{#if videoSrc}
<video controls class="mb-4 w-full" src={videoSrc}>
Your browser does not support the video element.
</video>
{:else}
<p class="mb-4 text-white/70">Video preview not available</p>
{/if}
<p class="text-sm text-white/50">{currentItem?.name || 'Video'}</p>
</div>
</div>
@@ -1,5 +1,5 @@
<script lang="ts">
import { Music, FileText } from '@lucide/svelte';
import { Music, Video, FileText } from '@lucide/svelte';
import { HorizontalScrollCarousel } from '$lib/components/app/misc';
interface PreviewItem {
@@ -7,6 +7,7 @@
name: string;
isImage: boolean;
isAudio: boolean;
isVideo: boolean;
preview?: string;
}
@@ -49,6 +50,8 @@
>
{#if item.isAudio}
<Music class="h-4 w-4 text-white/70" />
{:else if item.isVideo}
<Video class="h-4 w-4 text-white/70" />
{:else}
<FileText class="h-4 w-4 text-white/70" />
{/if}
@@ -23,6 +23,7 @@
class?: string;
disabled?: boolean;
hasAudioModality?: boolean;
hasVideoModality?: boolean;
hasVisionModality?: boolean;
hasMcpPromptsSupport?: boolean;
hasMcpResourcesSupport?: boolean;
@@ -37,6 +38,7 @@
class: className = '',
disabled = false,
hasAudioModality = false,
hasVideoModality = false,
hasVisionModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
@@ -58,6 +60,7 @@
() => ({
hasVisionModality,
hasAudioModality,
hasVideoModality,
hasMcpPromptsSupport,
hasMcpResourcesSupport
}),
@@ -19,6 +19,7 @@
class?: string;
disabled?: boolean;
hasAudioModality?: boolean;
hasVideoModality?: boolean;
hasVisionModality?: boolean;
hasMcpPromptsSupport?: boolean;
hasMcpResourcesSupport?: boolean;
@@ -34,6 +35,7 @@
disabled = false,
hasAudioModality = false,
hasVisionModality = false,
hasVideoModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
onFileUpload,
@@ -49,6 +51,7 @@
() => ({
hasVisionModality,
hasAudioModality,
hasVideoModality,
hasMcpPromptsSupport,
hasMcpResourcesSupport
}),
@@ -7,6 +7,7 @@
interface Props {
disabled?: boolean;
hasAudioModality?: boolean;
hasVideoModality?: boolean;
hasMcpPromptsSupport?: boolean;
hasMcpResourcesSupport?: boolean;
hasVisionModality?: boolean;
@@ -20,6 +21,7 @@
let {
disabled = false,
hasAudioModality = false,
hasVideoModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
hasVisionModality = false,
@@ -37,6 +39,7 @@
<ChatFormActionAddSheet
{disabled}
{hasAudioModality}
{hasVideoModality}
{hasVisionModality}
{hasMcpPromptsSupport}
{hasMcpResourcesSupport}
@@ -52,6 +55,7 @@
<ChatFormActionAddDropdown
{disabled}
{hasAudioModality}
{hasVideoModality}
{hasVisionModality}
{hasMcpPromptsSupport}
{hasMcpResourcesSupport}
@@ -11,6 +11,7 @@
disabled?: boolean;
forceForegroundText?: boolean;
hasAudioModality?: boolean;
hasVideoModality?: boolean;
hasVisionModality?: boolean;
hasModelSelected?: boolean;
isSelectedModelInCache?: boolean;
@@ -23,6 +24,7 @@
disabled = false,
forceForegroundText = false,
hasAudioModality = $bindable(false),
hasVideoModality = $bindable(false),
hasVisionModality = $bindable(false),
hasModelSelected = $bindable(false),
isSelectedModelInCache = $bindable(true),
@@ -95,6 +97,10 @@
hasAudioModality = activeModelId ? modelsStore.modelSupportsAudio(activeModelId) : false;
});
$effect(() => {
hasVideoModality = activeModelId ? modelsStore.modelSupportsVideo(activeModelId) : false;
});
$effect(() => {
void modelPropsVersion;
@@ -66,6 +66,7 @@
});
let hasAudioModality = $state(false);
let hasVideoModality = $state(false);
let hasVisionModality = $state(false);
let hasModelSelected = $state(false);
let isSelectedModelInCache = $state(true);
@@ -94,6 +95,7 @@
<ChatFormActionsAdd
{disabled}
{hasAudioModality}
{hasVideoModality}
{hasVisionModality}
{hasMcpPromptsSupport}
{hasMcpResourcesSupport}
@@ -111,6 +113,7 @@
{disabled}
bind:this={selectorModelRef}
bind:hasAudioModality
bind:hasVideoModality
bind:hasVisionModality
bind:hasModelSelected
bind:isSelectedModelInCache
@@ -379,9 +379,6 @@
border-radius: 1rem;
background: hsl(var(--muted) / 0.3);
color: var(--foreground);
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
'Liberation Mono', Menlo, monospace;
font-size: 0.875rem;
line-height: 1.6;
white-space: pre-wrap;
@@ -144,6 +144,16 @@
return false;
});
let hasVideoModality = $derived.by(() => {
if (activeModelId) {
void modelPropsVersion;
return modelsStore.modelSupportsVideo(activeModelId);
}
return false;
});
let hasVisionModality = $derived.by(() => {
if (activeModelId) {
void modelPropsVersion;
@@ -284,7 +294,11 @@
}
// Use model-specific capabilities for file validation
const capabilities = { hasVision: hasVisionModality, hasAudio: hasAudioModality };
const capabilities = {
hasVision: hasVisionModality,
hasAudio: hasAudioModality,
hasVideo: hasVideoModality
};
const { supportedFiles, unsupportedFiles, modalityReasons } = filterFilesByModalities(
generallySupported,
capabilities
@@ -297,6 +311,7 @@
if (hasVisionModality) supportedTypes.push('images');
if (hasAudioModality) supportedTypes.push('audio files');
if (hasVideoModality) supportedTypes.push('video files');
fileErrorData = {
generallyUnsupported,
@@ -742,9 +742,6 @@
padding: 0.125rem 0.375rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
'Liberation Mono', Menlo, monospace;
}
div :global(pre) {
@@ -80,12 +80,6 @@
</div>
<style>
.code-preview-wrapper {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
'Liberation Mono', Menlo, monospace;
}
.code-preview-wrapper pre {
background: transparent;
}
@@ -52,6 +52,15 @@ export const ATTACHMENT_FILE_ITEMS: AttachmentMenuItem[] = [
disabledTooltip: 'Audio files processing requires an audio model',
action: AttachmentAction.FILE_UPLOAD
},
{
id: AttachmentMenuItemId.VIDEO,
label: 'Video Files',
icon: FILE_TYPE_ICONS.video,
class: 'video-button',
enabledWhen: AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY,
disabledTooltip: 'Video files processing requires a video model',
action: AttachmentAction.FILE_UPLOAD
},
{
id: AttachmentMenuItemId.TEXT,
label: 'Text Files',
+7 -3
View File
@@ -8,13 +8,15 @@ import {
FileText as FileTextIcon,
Image as ImageIcon,
Eye as VisionIcon,
Mic as AudioIcon
Mic as AudioIcon,
Video as VideoIcon
} from '@lucide/svelte';
import { FileTypeCategory, ModelModality } from '$lib/enums';
export const FILE_TYPE_ICONS = {
[FileTypeCategory.IMAGE]: ImageIcon,
[FileTypeCategory.AUDIO]: AudioIcon,
[FileTypeCategory.VIDEO]: VideoIcon,
[FileTypeCategory.TEXT]: FileTextIcon,
[FileTypeCategory.PDF]: FileIcon
} as const;
@@ -23,10 +25,12 @@ export const DEFAULT_FILE_ICON = FileIcon;
export const MODALITY_ICONS = {
[ModelModality.VISION]: VisionIcon,
[ModelModality.AUDIO]: AudioIcon
[ModelModality.AUDIO]: AudioIcon,
[ModelModality.VIDEO]: VideoIcon
} as const;
export const MODALITY_LABELS = {
[ModelModality.VISION]: 'Vision',
[ModelModality.AUDIO]: 'Audio'
[ModelModality.AUDIO]: 'Audio',
[ModelModality.VIDEO]: 'Video'
} as const;
@@ -13,10 +13,12 @@ import {
FileTypePdf,
FileTypeText,
MimeTypeAudio,
MimeTypeVideo,
MimeTypeImage,
MimeTypeApplication,
MimeTypeText
} from '$lib/enums';
import { FileExtensionVideo, FileTypeVideo } from '$lib/enums/files';
// File type configuration using enums
export const AUDIO_FILE_TYPES = {
@@ -30,6 +32,17 @@ export const AUDIO_FILE_TYPES = {
}
} as const;
export const VIDEO_FILE_TYPES = {
[FileTypeVideo.MP4]: {
extensions: [FileExtensionVideo.MP4],
mimeTypes: [MimeTypeVideo.MP4]
},
[FileTypeVideo.OGG]: {
extensions: [FileExtensionVideo.OGG],
mimeTypes: [MimeTypeVideo.OGG]
}
} as const;
export const IMAGE_FILE_TYPES = {
[FileTypeImage.JPEG]: {
extensions: [FileExtensionImage.JPG, FileExtensionImage.JPEG],
+4 -1
View File
@@ -4,6 +4,7 @@
export enum AttachmentType {
AUDIO = 'AUDIO',
IMAGE = 'IMAGE',
VIDEO = 'VIDEO',
MCP_PROMPT = 'MCP_PROMPT',
MCP_RESOURCE = 'MCP_RESOURCE',
PDF = 'PDF',
@@ -18,6 +19,7 @@ export enum AttachmentType {
export enum AttachmentMenuItemId {
IMAGES = 'images',
AUDIO = 'audio',
VIDEO = 'video',
TEXT = 'text',
PDF = 'pdf',
SYSTEM_MESSAGE = 'system-message',
@@ -31,7 +33,8 @@ export enum AttachmentMenuItemId {
export enum AttachmentItemEnabledWhen {
ALWAYS = 'always',
HAS_VISION_MODALITY = 'hasVisionModality',
HAS_AUDIO_MODALITY = 'hasAudioModality'
HAS_AUDIO_MODALITY = 'hasAudioModality',
HAS_VIDEO_MODALITY = 'hasVideoModality'
}
/**
+2 -1
View File
@@ -39,7 +39,8 @@ export enum MessageType {
export enum ContentPartType {
TEXT = 'text',
IMAGE_URL = 'image_url',
INPUT_AUDIO = 'input_audio'
INPUT_AUDIO = 'input_audio',
INPUT_VIDEO = 'input_video'
}
/**
+16
View File
@@ -7,6 +7,7 @@
export enum FileTypeCategory {
IMAGE = 'image',
AUDIO = 'audio',
VIDEO = 'video',
PDF = 'pdf',
TEXT = 'text'
}
@@ -33,6 +34,11 @@ export enum FileTypeAudio {
WEBM = 'webm'
}
export enum FileTypeVideo {
MP4 = 'mp4',
OGG = 'ogg'
}
export enum FileTypePdf {
PDF = 'pdf'
}
@@ -92,6 +98,11 @@ export enum FileExtensionAudio {
WAV = '.wav'
}
export enum FileExtensionVideo {
MP4 = '.mp4',
OGG = '.ogg'
}
export enum FileExtensionPdf {
PDF = '.pdf'
}
@@ -176,6 +187,11 @@ export enum MimeTypeAudio {
WEBM_OPUS = 'audio/webm;codecs=opus'
}
export enum MimeTypeVideo {
MP4 = 'video/mp4',
OGG = 'video/ogg'
}
export enum MimeTypeImage {
JPEG = 'image/jpeg',
JPG = 'image/jpg',
+1
View File
@@ -34,6 +34,7 @@ export {
UriPattern,
MimeTypeApplication,
MimeTypeAudio,
MimeTypeVideo,
MimeTypeImage,
MimeTypeText,
SpecialFileType
+2 -1
View File
@@ -1,5 +1,6 @@
export enum ModelModality {
TEXT = 'TEXT',
AUDIO = 'AUDIO',
VISION = 'VISION'
VISION = 'VISION',
VIDEO = 'VIDEO'
}
@@ -4,6 +4,7 @@ import { AttachmentAction } from '$lib/enums';
export interface AttachmentModalityFlags {
hasVisionModality: boolean;
hasAudioModality: boolean;
hasVideoModality: boolean;
hasMcpPromptsSupport: boolean;
hasMcpResourcesSupport: boolean;
}
+19
View File
@@ -888,6 +888,25 @@ export class ChatService {
});
}
const videoFiles = message.extra.filter(
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraVideoFile =>
extra.type === AttachmentType.VIDEO
);
for (const video of videoFiles) {
contentParts.push({
type: ContentPartType.INPUT_VIDEO,
input_video: {
data: video.base64Data,
format: video.mimeType.includes('mp4')
? 'mp4'
: video.mimeType.includes('ogg')
? 'ogg'
: 'auto'
}
});
}
const pdfFiles = message.extra.filter(
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraPdfFile =>
extra.type === AttachmentType.PDF
+16 -4
View File
@@ -148,7 +148,8 @@ class ModelsStore {
if (props?.modalities) {
return {
vision: props.modalities.vision ?? false,
audio: props.modalities.audio ?? false
audio: props.modalities.audio ?? false,
video: props.modalities.video ?? false
};
}
@@ -169,6 +170,13 @@ class ModelsStore {
return this.getModelModalities(modelId)?.audio ?? false;
}
/**
* Check if a model supports video modality
*/
modelSupportsVideo(modelId: string): boolean {
return this.getModelModalities(modelId)?.video ?? false;
}
/**
* Get model modalities as an array of ModelModality enum values
*/
@@ -180,6 +188,7 @@ class ModelsStore {
if (modalities.vision) result.push(ModelModality.VISION);
if (modalities.audio) result.push(ModelModality.AUDIO);
if (modalities.video) result.push(ModelModality.VIDEO);
return result;
}
@@ -316,7 +325,8 @@ class ModelsStore {
if (serverStore.isModelMode && this.models.length > 0 && serverProps?.modalities) {
const modalities: ModelModalities = {
vision: serverProps.modalities.vision ?? false,
audio: serverProps.modalities.audio ?? false
audio: serverProps.modalities.audio ?? false,
video: serverProps.modalities.video ?? false
};
this.modelPropsCache.set(this.models[0].model, serverProps);
this.models = this.models.map((model, index) =>
@@ -410,7 +420,8 @@ class ModelsStore {
const modalities: ModelModalities = {
vision: props.modalities.vision ?? false,
audio: props.modalities.audio ?? false
audio: props.modalities.audio ?? false,
video: props.modalities.video ?? false
};
return { ...model, modalities };
@@ -529,7 +540,8 @@ class ModelsStore {
const modalities: ModelModalities = {
vision: props.modalities.vision ?? false,
audio: props.modalities.audio ?? false
audio: props.modalities.audio ?? false,
video: props.modalities.video ?? false
};
this.models = this.models.map((model) =>
+5
View File
@@ -22,6 +22,10 @@ export interface ApiChatMessageContentPart {
data: string;
format: 'wav' | 'mp3';
};
input_video?: {
data: string;
format: 'mp4' | 'ogg' | 'auto';
};
}
export interface ApiContextSizeError {
@@ -190,6 +194,7 @@ export interface ApiLlamaCppServerProps {
modalities: {
vision: boolean;
audio: boolean;
video: boolean;
};
chat_template: string;
bos_token: string;
+6 -1
View File
@@ -64,4 +64,9 @@ export interface ParsedClipboardContent {
mcpPromptAttachments: ClipboardMcpPromptAttachment[];
}
export type MimeTypeUnion = MimeTypeAudio | MimeTypeImage | MimeTypeApplication | MimeTypeText;
export type MimeTypeUnion =
| MimeTypeAudio
| MimeTypeVideo
| MimeTypeImage
| MimeTypeApplication
| MimeTypeText;
+9
View File
@@ -23,6 +23,14 @@ export interface DatabaseMessageExtraAudioFile {
mimeType: string;
}
export interface DatabaseMessageExtraVideoFile {
type: AttachmentType.VIDEO;
name: string;
size?: number;
base64Data: string;
mimeType: string;
}
export interface DatabaseMessageExtraImageFile {
type: AttachmentType.IMAGE;
name: string;
@@ -82,6 +90,7 @@ export type DatabaseMessageExtra =
| DatabaseMessageExtraImageFile
| DatabaseMessageExtraTextFile
| DatabaseMessageExtraAudioFile
| DatabaseMessageExtraVideoFile
| DatabaseMessageExtraPdfFile
| DatabaseMessageExtraMcpPrompt
| DatabaseMessageExtraMcpResource
+1
View File
@@ -55,6 +55,7 @@ export type {
McpServerOverride,
DatabaseConversation,
DatabaseMessageExtraAudioFile,
DatabaseMessageExtraVideoFile,
DatabaseMessageExtraImageFile,
DatabaseMessageExtraLegacyContext,
DatabaseMessageExtraMcpPrompt,
+2
View File
@@ -3,6 +3,7 @@ import type { ApiModelDataEntry, ApiModelDetails } from '$lib/types/api';
export interface ModelModalities {
vision: boolean;
audio: boolean;
video: boolean;
}
export interface ModelOption {
@@ -35,4 +36,5 @@ export interface ParsedModelId {
export interface ModalityCapabilities {
hasVision: boolean;
hasAudio: boolean;
hasVideo: boolean;
}
+21
View File
@@ -103,3 +103,24 @@ export function isAudioFile(
return false;
}
/**
* Determines if an attachment or uploaded file is a video file
* @param uploadedFile - Optional uploaded file
* @param attachment - Optional database attachment
* @returns true if the file is a video file
*/
export function isVideoFile(
attachment?: DatabaseMessageExtra,
uploadedFile?: ChatUploadedFile
): boolean {
if (uploadedFile) {
return getUploadedFileCategory(uploadedFile) === FileTypeCategory.VIDEO;
}
if (attachment) {
return attachment.type === AttachmentType.VIDEO;
}
return false;
}
@@ -89,6 +89,21 @@ export async function parseFilesToMessageExtras(
} catch (error) {
console.error(`Failed to process audio file ${file.name}:`, error);
}
} else if (getFileTypeCategory(file.type) === FileTypeCategory.VIDEO) {
// Process video files (MP4, etc)
try {
const base64Data = await readFileAsBase64(file.file);
extras.push({
type: AttachmentType.VIDEO,
name: file.name,
size: file.size,
base64Data: base64Data,
mimeType: file.type
});
} catch (error) {
console.error(`Failed to process video file ${file.name}:`, error);
}
} else if (getFileTypeCategory(file.type) === FileTypeCategory.PDF) {
try {
// Always get base64 data for preview functionality
+13
View File
@@ -1,5 +1,6 @@
import {
AUDIO_FILE_TYPES,
VIDEO_FILE_TYPES,
IMAGE_FILE_TYPES,
PDF_FILE_TYPES,
TEXT_FILE_TYPES
@@ -12,6 +13,7 @@ import {
FileTypeCategory,
MimeTypeApplication,
MimeTypeAudio,
MimeTypeVideo,
MimeTypeImage,
MimeTypeText
} from '$lib/enums';
@@ -35,6 +37,11 @@ export function getFileTypeCategory(mimeType: string): FileTypeCategory | null {
case MimeTypeAudio.WEBM_OPUS:
return FileTypeCategory.AUDIO;
// Video
case MimeTypeVideo.MP4:
case MimeTypeVideo.OGG:
return FileTypeCategory.VIDEO;
// PDF
case MimeTypeApplication.PDF:
return FileTypeCategory.PDF;
@@ -179,6 +186,12 @@ export function getFileTypeByExtension(filename: string): string | null {
}
}
for (const [key, type] of Object.entries(VIDEO_FILE_TYPES)) {
if ((type.extensions as readonly string[]).includes(extension)) {
return `${FileTypeCategory.VIDEO}:${key}`;
}
}
for (const [key, type] of Object.entries(PDF_FILE_TYPES)) {
if ((type.extensions as readonly string[]).includes(extension)) {
return `${FileTypeCategory.PDF}:${key}`;
+1 -1
View File
@@ -14,7 +14,7 @@ export { validateApiKey } from './api-key-validation';
// Attachment utilities
export { getAttachmentDisplayItems, isMcpPrompt, isMcpResource } from './attachment-display';
export { isTextFile, isImageFile, isPdfFile, isAudioFile } from './attachment-type';
export { isTextFile, isImageFile, isPdfFile, isAudioFile, isVideoFile } from './attachment-type';
// Textarea utilities
export { default as autoResizeTextarea } from './autoresize-textarea';
@@ -45,6 +45,10 @@ export function isFileTypeSupportedByModel(
// Audio files require audio support
return capabilities.hasAudio;
case FileTypeCategory.VIDEO:
// Video files require video support
return capabilities.hasVideo;
default:
// Unknown categories - be conservative and allow
return true;
@@ -69,7 +73,7 @@ export function filterFilesByModalities(
const unsupportedFiles: File[] = [];
const modalityReasons: Record<string, string> = {};
const { hasVision, hasAudio } = capabilities;
const { hasVision, hasAudio, hasVideo } = capabilities;
for (const file of files) {
const category = getFileTypeCategory(file.type);
@@ -91,6 +95,13 @@ export function filterFilesByModalities(
}
break;
case FileTypeCategory.VIDEO:
if (!hasVideo) {
isSupported = false;
reason = 'Video files require a video-capable model';
}
break;
case FileTypeCategory.TEXT:
case FileTypeCategory.PDF:
// Always supported
@@ -127,7 +138,7 @@ export function generateModalityErrorMessage(
): string {
if (unsupportedFiles.length === 0) return '';
const { hasVision, hasAudio } = capabilities;
const { hasVision, hasAudio, hasVideo } = capabilities;
let message = '';
@@ -144,6 +155,7 @@ export function generateModalityErrorMessage(
const supportedTypes: string[] = ['text files', 'PDFs'];
if (hasVision) supportedTypes.push('images');
if (hasAudio) supportedTypes.push('audio files');
if (hasVideo) supportedTypes.push('video files');
message += ` This model supports: ${supportedTypes.join(', ')}.`;
@@ -117,6 +117,10 @@ export async function processFilesToChatUploaded(
// Generate preview URL for audio files
const preview = await readFileAsDataURL(file);
results.push({ ...base, preview });
} else if (getFileTypeCategory(file.type) === FileTypeCategory.VIDEO) {
// Generate preview URL for video files
const preview = await readFileAsDataURL(file);
results.push({ ...base, preview });
} else {
// Fallback: treat unknown files as text
try {
@@ -15,7 +15,8 @@ export function mockServerProps(props: Partial<ApiLlamaCppServerProps>): void {
model_path: props.model_path || 'test-model',
modalities: {
vision: props.modalities?.vision ?? false,
audio: props.modalities?.audio ?? false
audio: props.modalities?.audio ?? false,
video: props.modalities?.video ?? false
},
...props
} as ApiLlamaCppServerProps;
@@ -26,11 +27,14 @@ export function mockServerProps(props: Partial<ApiLlamaCppServerProps>): void {
// Also mock modelsStore methods for modality checking
const vision = props.modalities?.vision ?? false;
const audio = props.modalities?.audio ?? false;
const video = props.modalities?.video ?? false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(modelsStore as any).modelSupportsVision = () => vision;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(modelsStore as any).modelSupportsAudio = () => audio;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(modelsStore as any).modelSupportsVideo = () => video;
// Mock models list with a test model so activeModelId can be resolved
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -55,7 +59,8 @@ export function resetServerStore(): void {
model_path: '',
modalities: {
vision: false,
audio: false
audio: false,
video: false
}
} as ApiLlamaCppServerProps;
(serverStore as unknown as { error: string }).error = '';
@@ -76,6 +81,6 @@ export const mockConfigs = {
modalities: { vision: true, audio: true }
},
noModalities: {
modalities: { vision: false, audio: false }
modalities: { vision: false, audio: false, video: false }
}
} as const;