Unverified Commit d3b4b997 authored by Daniel Hiltgen's avatar Daniel Hiltgen Committed by GitHub
Browse files

app: add code for macOS and Windows apps under 'app' (#12933)



* app: add code for macOS and Windows apps under 'app'

* app: add readme

* app: windows and linux only for now

* ci: fix ui CI validation

---------
Co-authored-by: default avatarjmorganca <jmorganca@gmail.com>
parent a4770107
package commontray
var (
Title = "Ollama"
ToolTip = "Ollama"
UpdateIconName = "tray_upgrade"
IconName = "tray"
)
type Callbacks struct {
Quit chan struct{}
Update chan struct{}
DoFirstUse chan struct{}
ShowLogs chan struct{}
}
type OllamaTray interface {
GetCallbacks() Callbacks
Run()
UpdateAvailable(ver string) error
DisplayFirstUseNotification() error
Quit()
}
package tray
import (
"fmt"
"runtime"
"github.com/ollama/ollama/app/assets"
"github.com/ollama/ollama/app/tray/commontray"
)
func NewTray() (commontray.OllamaTray, error) {
extension := ".png"
if runtime.GOOS == "windows" {
extension = ".ico"
}
iconName := commontray.UpdateIconName + extension
updateIcon, err := assets.GetIcon(iconName)
if err != nil {
return nil, fmt.Errorf("failed to load icon %s: %w", iconName, err)
}
iconName = commontray.IconName + extension
icon, err := assets.GetIcon(iconName)
if err != nil {
return nil, fmt.Errorf("failed to load icon %s: %w", iconName, err)
}
return InitPlatformTray(icon, updateIcon)
}
//go:build !windows
package tray
import (
"errors"
"github.com/ollama/ollama/app/tray/commontray"
)
func InitPlatformTray(icon, updateIcon []byte) (commontray.OllamaTray, error) {
return nil, errors.New("not implemented")
}
package tray
import (
"github.com/ollama/ollama/app/tray/commontray"
"github.com/ollama/ollama/app/tray/wintray"
)
func InitPlatformTray(icon, updateIcon []byte) (commontray.OllamaTray, error) {
return wintray.InitTray(icon, updateIcon)
}
//go:build windows || darwin
package not
import (
"errors"
)
// Found is an error that indicates that a value was not found. It
// may be used by low-level packages to signal to higher-level
// packages that a value was not found.
//
// It exists to avoid using errors.New("not found") in multiple
// packages to mean the same thing.
//
// Found should not be used directly. Instead it should be wrapped
// or joined using errors.Join or fmt.Errorf, etc.
//
// Errors wrapping Found should provide additional context, e.g.
// fmt.Errorf("%w: %s", not.Found, key)
//
//lint:ignore ST1012 This is a sentinel error intended to be read like not.Found.
var Found = errors.New("not found")
// Available is an error that indicates that a value is not available.
//
//lint:ignore ST1012 This is a sentinel error intended to be read like not.Available.
var Available = errors.New("not available")
//go:build windows || darwin
package not
import (
"fmt"
)
type ValidError struct {
name string
msg string
args []any
}
// Valid returns a new validation error with the given name and message.
func Valid(name, message string, args ...any) error {
return ValidError{name, message, args}
}
// Message returns the formatted message for the validation error.
func (e *ValidError) Message() string {
return fmt.Sprintf(e.msg, e.args...)
}
// Error implements the error interface.
func (e ValidError) Error() string {
return fmt.Sprintf("invalid %s: %s", e.name, e.Message())
}
func (e ValidError) Field() string {
return e.name
}
// Valids is for building a list of validation errors.
type Valids []ValidError
// Addf adds a validation error to the list with a formatted message using fmt.Sprintf.
func (b *Valids) Add(name, message string, args ...any) {
*b = append(*b, ValidError{name, message, args})
}
func (b Valids) Error() string {
if len(b) == 0 {
return ""
}
var result string
for i, err := range b {
if i > 0 {
result += "; "
}
result += err.Error()
}
return result
}
//go:build windows || darwin
package not_test
import (
"errors"
"fmt"
"github.com/ollama/ollama/app/types/not"
)
func ExampleValids() {
// This example demonstrates how to use the Valids type to create
// a list of validation errors.
//
// The Valids type is a slice of ValidError values. Each ValidError
// value represents a validation error.
//
// The Valids type has an Error method that returns a single error
// value that represents all of the validation errors in the list.
//
// The Valids type is useful for collecting multiple validation errors
// and returning them as a single error value.
validate := func() error {
var b not.Valids
b.Add("name", "must be a valid name")
b.Add("email", "%q: must be a valid email address", "invalid.email")
return b
}
err := validate()
var nv not.Valids
if errors.As(err, &nv) {
for _, v := range nv {
fmt.Println(v)
}
}
// Output:
// invalid name: must be a valid name
// invalid email: "invalid.email": must be a valid email address
}
//go:build windows || darwin
package ui
import (
"bytes"
"embed"
"errors"
"io/fs"
"net/http"
"strings"
"time"
)
//go:embed app/dist
var appFS embed.FS
// appHandler returns an HTTP handler that serves the React SPA.
// It tries to serve real files first, then falls back to index.html for React Router.
func (s *Server) appHandler() http.Handler {
// Strip the dist prefix so URLs look clean
fsys, _ := fs.Sub(appFS, "app/dist")
fileServer := http.FileServer(http.FS(fsys))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
if _, err := fsys.Open(p); err == nil {
// Serve the file directly
fileServer.ServeHTTP(w, r)
return
}
// Fallback – serve index.html for unknown paths so React Router works
data, err := fs.ReadFile(fsys, "index.html")
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
http.NotFound(w, r)
} else {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(data))
})
}
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.vite/
.claude/
*storybook.log
storybook-static
*.gen.ts
\ No newline at end of file
{
"trailingComma": "all",
"semi": true,
"singleQuote": false,
"printWidth": 80
}
/* Do not change, this code is generated from Golang structs */
export class ChatInfo {
id: string;
title: string;
userExcerpt: string;
createdAt: Date;
updatedAt: Date;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.title = source["title"];
this.userExcerpt = source["userExcerpt"];
this.createdAt = new Date(source["createdAt"]);
this.updatedAt = new Date(source["updatedAt"]);
}
}
export class ChatsResponse {
chatInfos: ChatInfo[];
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.chatInfos = this.convertValues(source["chatInfos"], ChatInfo);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Time {
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
}
}
export class ToolFunction {
name: string;
arguments: string;
result?: any;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.arguments = source["arguments"];
this.result = source["result"];
}
}
export class ToolCall {
type: string;
function: ToolFunction;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.type = source["type"];
this.function = this.convertValues(source["function"], ToolFunction);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class File {
filename: string;
data: number[];
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.filename = source["filename"];
this.data = source["data"];
}
}
export class Message {
role: string;
content: string;
thinking: string;
stream: boolean;
model?: string;
attachments?: File[];
tool_calls?: ToolCall[];
tool_call?: ToolCall;
tool_name?: string;
tool_result?: number[];
created_at: Time;
updated_at: Time;
thinkingTimeStart?: Date | undefined;
thinkingTimeEnd?: Date | undefined;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.role = source["role"];
this.content = source["content"];
this.thinking = source["thinking"];
this.stream = source["stream"];
this.model = source["model"];
this.attachments = this.convertValues(source["attachments"], File);
this.tool_calls = this.convertValues(source["tool_calls"], ToolCall);
this.tool_call = this.convertValues(source["tool_call"], ToolCall);
this.tool_name = source["tool_name"];
this.tool_result = source["tool_result"];
this.created_at = this.convertValues(source["created_at"], Time);
this.updated_at = this.convertValues(source["updated_at"], Time);
this.thinkingTimeStart = source["thinkingTimeStart"] && new Date(source["thinkingTimeStart"]);
this.thinkingTimeEnd = source["thinkingTimeEnd"] && new Date(source["thinkingTimeEnd"]);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Chat {
id: string;
messages: Message[];
title: string;
created_at: Time;
browser_state?: BrowserStateData;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.messages = this.convertValues(source["messages"], Message);
this.title = source["title"];
this.created_at = this.convertValues(source["created_at"], Time);
this.browser_state = source["browser_state"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ChatResponse {
chat: Chat;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.chat = this.convertValues(source["chat"], Chat);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Model {
model: string;
digest?: string;
modified_at?: Time;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.model = source["model"];
this.digest = source["digest"];
this.modified_at = this.convertValues(source["modified_at"], Time);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ModelsResponse {
models: Model[];
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.models = this.convertValues(source["models"], Model);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class InferenceCompute {
library: string;
variant: string;
compute: string;
driver: string;
name: string;
vram: string;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.library = source["library"];
this.variant = source["variant"];
this.compute = source["compute"];
this.driver = source["driver"];
this.name = source["name"];
this.vram = source["vram"];
}
}
export class InferenceComputeResponse {
inferenceComputes: InferenceCompute[];
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.inferenceComputes = this.convertValues(source["inferenceComputes"], InferenceCompute);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ModelCapabilitiesResponse {
capabilities: string[];
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.capabilities = source["capabilities"];
}
}
export class ChatEvent {
eventName: "chat" | "thinking" | "assistant_with_tools" | "tool_call" | "tool" | "tool_result" | "done" | "chat_created";
content?: string;
thinking?: string;
thinkingTimeStart?: Date | undefined;
thinkingTimeEnd?: Date | undefined;
toolCalls?: ToolCall[];
toolCall?: ToolCall;
toolName?: string;
toolResult?: boolean;
toolResultData?: any;
chatId?: string;
toolState?: any;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.eventName = source["eventName"];
this.content = source["content"];
this.thinking = source["thinking"];
this.thinkingTimeStart = source["thinkingTimeStart"] && new Date(source["thinkingTimeStart"]);
this.thinkingTimeEnd = source["thinkingTimeEnd"] && new Date(source["thinkingTimeEnd"]);
this.toolCalls = this.convertValues(source["toolCalls"], ToolCall);
this.toolCall = this.convertValues(source["toolCall"], ToolCall);
this.toolName = source["toolName"];
this.toolResult = source["toolResult"];
this.toolResultData = source["toolResultData"];
this.chatId = source["chatId"];
this.toolState = source["toolState"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class DownloadEvent {
eventName: "download";
total: number;
completed: number;
done: boolean;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.eventName = source["eventName"];
this.total = source["total"];
this.completed = source["completed"];
this.done = source["done"];
}
}
export class ErrorEvent {
eventName: "error";
error: string;
code?: string;
details?: string;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.eventName = source["eventName"];
this.error = source["error"];
this.code = source["code"];
this.details = source["details"];
}
}
export class Settings {
Expose: boolean;
Browser: boolean;
Survey: boolean;
Models: string;
Agent: boolean;
Tools: boolean;
WorkingDir: string;
ContextLength: number;
AirplaneMode: boolean;
TurboEnabled: boolean;
WebSearchEnabled: boolean;
ThinkEnabled: boolean;
ThinkLevel: string;
SelectedModel: string;
SidebarOpen: boolean;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.Expose = source["Expose"];
this.Browser = source["Browser"];
this.Survey = source["Survey"];
this.Models = source["Models"];
this.Agent = source["Agent"];
this.Tools = source["Tools"];
this.WorkingDir = source["WorkingDir"];
this.ContextLength = source["ContextLength"];
this.AirplaneMode = source["AirplaneMode"];
this.TurboEnabled = source["TurboEnabled"];
this.WebSearchEnabled = source["WebSearchEnabled"];
this.ThinkEnabled = source["ThinkEnabled"];
this.ThinkLevel = source["ThinkLevel"];
this.SelectedModel = source["SelectedModel"];
this.SidebarOpen = source["SidebarOpen"];
}
}
export class SettingsResponse {
settings: Settings;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.settings = this.convertValues(source["settings"], Settings);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class HealthResponse {
healthy: boolean;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.healthy = source["healthy"];
}
}
export class User {
id: string;
name: string;
email: string;
avatarURL: string;
plan: string;
bio: string;
firstName: string;
lastName: string;
overThreshold: boolean;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.email = source["email"];
this.avatarURL = source["avatarURL"];
this.plan = source["plan"];
this.bio = source["bio"];
this.firstName = source["firstName"];
this.lastName = source["lastName"];
this.overThreshold = source["overThreshold"];
}
}
export class Attachment {
filename: string;
data?: string;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.filename = source["filename"];
this.data = source["data"];
}
}
export class ChatRequest {
model: string;
prompt: string;
index?: number;
attachments?: Attachment[];
web_search?: boolean;
file_tools?: boolean;
forceUpdate?: boolean;
think?: any;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.model = source["model"];
this.prompt = source["prompt"];
this.index = source["index"];
this.attachments = this.convertValues(source["attachments"], Attachment);
this.web_search = source["web_search"];
this.file_tools = source["file_tools"];
this.forceUpdate = source["forceUpdate"];
this.think = source["think"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Error {
error: string;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.error = source["error"];
}
}
export class ModelUpstreamResponse {
digest?: string;
pushTime: number;
error?: string;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.digest = source["digest"];
this.pushTime = source["pushTime"];
this.error = source["error"];
}
}
export class Page {
url: string;
title: string;
text: string;
lines: string[];
links?: Record<number, string>;
fetched_at: Time;
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.url = source["url"];
this.title = source["title"];
this.text = source["text"];
this.lines = source["lines"];
this.links = source["links"];
this.fetched_at = this.convertValues(source["fetched_at"], Time);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (Array.isArray(a)) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class BrowserStateData {
page_stack: string[];
view_tokens: number;
url_to_page: {[key: string]: Page};
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.page_stack = source["page_stack"];
this.view_tokens = source["view_tokens"];
this.url_to_page = source["url_to_page"];
}
}
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from "eslint-plugin-storybook";
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
storybook.configs["flat/recommended"],
);
<!doctype html>
<html lang="en" style="overflow: hidden">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/src/index.css" />
<title>Ollama</title>
</head>
<body class="dark:bg-neutral-900 select-text">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
// Add selectFiles method if available
if (typeof window.selectFiles === "function") {
window.webview = window.webview || {};
// Single file selection (returns first file or null)
window.webview.selectFile = function () {
return new Promise((resolve) => {
window.__selectFilesCallback = (data) => {
window.__selectFilesCallback = null;
// For single file, return first file or null
resolve(data && data.length > 0 ? data[0] : null);
};
window.selectFiles();
});
};
// Multiple file selection (returns array or null)
window.webview.selectMultipleFiles = function () {
return new Promise((resolve) => {
window.__selectFilesCallback = (data) => {
window.__selectFilesCallback = null;
resolve(data); // Returns array of files or null if cancelled
};
window.selectFiles();
});
};
}
// Add directory selection methods if available
if (typeof window.selectModelsDirectory === "function") {
window.webview = window.webview || {};
window.webview.selectModelsDirectory = function () {
return new Promise((resolve) => {
window.__selectModelsDirectoryCallback = (path) => {
window.__selectModelsDirectoryCallback = null;
resolve(path); // Returns directory path or null if cancelled
};
window.selectModelsDirectory();
});
};
}
if (typeof window.selectWorkingDirectory === "function") {
window.webview = window.webview || {};
window.webview.selectWorkingDirectory = function () {
return new Promise((resolve) => {
window.__selectWorkingDirectoryCallback = (path) => {
window.__selectWorkingDirectoryCallback = null;
resolve(path); // Returns directory path or null if cancelled
};
window.selectWorkingDirectory();
});
};
}
if (typeof window.ready === "function") {
const callReady = () => setTimeout(window.ready, 100);
if (document.readyState === "complete") {
callReady();
} else {
window.addEventListener("load", callReady);
}
}
if (typeof window.resize === "function") {
window.addEventListener("resize", function () {
window.resize(window.innerWidth, window.innerHeight);
});
}
document.addEventListener("keydown", function (e) {
if (
e.key === "Backspace" &&
!e.target.matches("input, textarea, [contenteditable], select")
) {
e.preventDefault();
}
// Only prevent navigation shortcuts when not in editable fields
if (!e.target.matches("input, textarea, [contenteditable], select")) {
// Prevent Cmd/Ctrl + Left/Right arrow navigation
if (
(e.ctrlKey || e.metaKey) &&
(e.key === "ArrowLeft" || e.key === "ArrowRight")
) {
e.preventDefault();
return false;
}
// Prevent Alt + Left/Right arrow navigation (Windows/Linux)
if (e.altKey && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
e.preventDefault();
return false;
}
}
// Always prevent F5 refresh
if (e.key === "F5") {
e.preventDefault();
return false;
}
// Always prevent Ctrl/Cmd + Shift + R (hard refresh)
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === "r") {
e.preventDefault();
return false;
}
});
// Prevent mouse button navigation (back/forward buttons)
document.addEventListener("mousedown", function (e) {
// Mouse button 3 is back, button 4 is forward
if (e.button === 3 || e.button === 4) {
e.preventDefault();
return false;
}
});
// Prevent drag and drop navigation
document.addEventListener("dragover", function (e) {
e.preventDefault();
return false;
});
document.addEventListener("drop", function (e) {
e.preventDefault();
return false;
});
// TODO (jmorganca): this is a way for different components to elect
// to show custom context menu items on top of the default one
// we should integrate this better since it's confusing to follow
document.addEventListener(
"contextmenu",
function (e) {
window.setContextMenuItems([]);
let target = e.target;
while (target && target !== document) {
if (
target.classList &&
target.classList.contains("allow-context-menu")
) {
return true;
}
target = target.parentElement;
}
e.preventDefault();
return false;
},
true,
);
let pendingMenuItems = [];
let menuPromiseResolve = null;
let menuPromiseReject = null;
window.menu = function (items) {
return new Promise((resolve, reject) => {
pendingMenuItems = items;
menuPromiseResolve = resolve;
menuPromiseReject = reject;
window.setContextMenuItems(items);
});
};
window.handleContextMenuResult = function (selected) {
if (menuPromiseResolve) {
menuPromiseResolve(selected);
menuPromiseResolve = null;
menuPromiseReject = null;
}
pendingMenuItems = [];
};
</script>
</body>
</html>
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"prettier": "prettier --write .",
"prettier:check": "prettier --check .",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
},
"dependencies": {
"@headlessui/react": "^2.2.4",
"@heroicons/react": "^2.2.0",
"@tanstack/react-query": "^5.80.7",
"@tanstack/react-router": "^1.120.20",
"@tanstack/react-router-devtools": "^1.120.20",
"clsx": "^2.1.1",
"framer-motion": "^12.17.0",
"katex": "^0.16.22",
"micromark-extension-llm-math": "^3.1.0",
"ollama": "^0.6.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"rehype-katex": "^7.0.1",
"rehype-prism-plus": "^2.0.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-math": "^6.0.0",
"unist-builder": "^4.0.0",
"unist-util-parents": "^3.0.0"
},
"devDependencies": {
"@chromatic-com/storybook": "^4.0.1",
"@eslint/js": "^9.25.0",
"@storybook/addon-a11y": "^9.0.14",
"@storybook/addon-docs": "^9.0.14",
"@storybook/addon-onboarding": "^9.0.14",
"@storybook/addon-vitest": "^9.0.14",
"@storybook/react-vite": "^9.0.14",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/router-plugin": "^1.120.20",
"@types/node": "^24.7.2",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"autoprefixer": "^10.4.21",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"eslint-plugin-storybook": "^9.0.14",
"globals": "^16.0.0",
"playwright": "^1.53.2",
"postcss-preset-env": "^10.2.4",
"react-markdown": "^10.1.0",
"remark": "^15.0.1",
"remark-gfm": "^4.0.1",
"remark-stringify": "^11.0.0",
"storybook": "^9.0.14",
"tailwindcss": "^4.1.9",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"
},
"overrides": {
"mdast-util-gfm-autolink-literal": "2.0.0"
}
}
import {
ChatResponse,
ChatsResponse,
ChatEvent,
DownloadEvent,
ErrorEvent,
InferenceCompute,
InferenceComputeResponse,
ModelCapabilitiesResponse,
Model,
ChatRequest,
Settings,
User,
} from "@/gotypes";
import { parseJsonlFromResponse } from "./util/jsonl-parsing";
import { ollamaClient as ollama } from "./lib/ollama-client";
import type { ModelResponse } from "ollama/browser";
// Extend Model class with utility methods
declare module "@/gotypes" {
interface Model {
isCloud(): boolean;
}
}
Model.prototype.isCloud = function (): boolean {
return this.model.endsWith("cloud");
};
const API_BASE = import.meta.env.DEV ? "http://127.0.0.1:3001" : "";
// Helper function to convert Uint8Array to base64
function uint8ArrayToBase64(uint8Array: Uint8Array): string {
const chunkSize = 0x8000; // 32KB chunks to avoid stack overflow
let binary = "";
for (let i = 0; i < uint8Array.length; i += chunkSize) {
const chunk = uint8Array.subarray(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
export async function fetchUser(): Promise<User | null> {
try {
const response = await fetch(`${API_BASE}/api/v1/me`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (response.ok) {
const userData: User = await response.json();
return userData;
}
return null;
} catch (error) {
console.error("Error fetching user:", error);
return null;
}
}
export async function fetchConnectUrl(): Promise<string> {
const response = await fetch(`${API_BASE}/api/v1/connect`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error("Failed to fetch connect URL");
}
const data = await response.json();
return data.connect_url;
}
export async function disconnectUser(): Promise<void> {
const response = await fetch(`${API_BASE}/api/v1/disconnect`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error("Failed to disconnect user");
}
}
export async function getChats(): Promise<ChatsResponse> {
const response = await fetch(`${API_BASE}/api/v1/chats`);
const data = await response.json();
return new ChatsResponse(data);
}
export async function getChat(chatId: string): Promise<ChatResponse> {
const response = await fetch(`${API_BASE}/api/v1/chat/${chatId}`);
const data = await response.json();
return new ChatResponse(data);
}
export async function getModels(query?: string): Promise<Model[]> {
try {
const { models: modelsResponse } = await ollama.list();
let models: Model[] = modelsResponse
.filter((m: ModelResponse) => {
const families = m.details?.families;
if (!families || families.length === 0) {
return true;
}
const isBertOnly = families.every((family: string) =>
family.toLowerCase().includes("bert"),
);
return !isBertOnly;
})
.map((m: ModelResponse) => {
// Remove the latest tag from the returned model
const modelName = m.name.replace(/:latest$/, "");
return new Model({
model: modelName,
digest: m.digest,
modified_at: m.modified_at ? new Date(m.modified_at) : undefined,
});
});
// Filter by query if provided
if (query) {
const normalizedQuery = query.toLowerCase().trim();
const filteredModels = models.filter((m: Model) => {
return m.model.toLowerCase().startsWith(normalizedQuery);
});
let exactMatch = false;
for (const m of filteredModels) {
if (m.model.toLowerCase() === normalizedQuery) {
exactMatch = true;
break;
}
}
// Add query if it's in the registry and not already in the list
if (!exactMatch) {
const result = await getModelUpstreamInfo(new Model({ model: query }));
const existsUpstream = !!result.digest && !result.error;
if (existsUpstream) {
filteredModels.push(new Model({ model: query }));
}
}
models = filteredModels;
}
return models;
} catch (err) {
throw new Error(`Failed to fetch models: ${err}`);
}
}
export async function getModelCapabilities(
modelName: string,
): Promise<ModelCapabilitiesResponse> {
try {
const showResponse = await ollama.show({ model: modelName });
return new ModelCapabilitiesResponse({
capabilities: Array.isArray(showResponse.capabilities)
? showResponse.capabilities
: [],
});
} catch (error) {
// Model might not be downloaded yet, return empty capabilities
console.error(`Failed to get capabilities for ${modelName}:`, error);
return new ModelCapabilitiesResponse({ capabilities: [] });
}
}
export type ChatEventUnion = ChatEvent | DownloadEvent | ErrorEvent;
export async function* sendMessage(
chatId: string,
message: string,
model: Model,
attachments?: Array<{ filename: string; data: Uint8Array }>,
signal?: AbortSignal,
index?: number,
webSearch?: boolean,
fileTools?: boolean,
forceUpdate?: boolean,
think?: boolean | string,
): AsyncGenerator<ChatEventUnion> {
// Convert Uint8Array to base64 for JSON serialization
const serializedAttachments = attachments?.map((att) => ({
filename: att.filename,
data: uint8ArrayToBase64(att.data),
}));
const response = await fetch(`${API_BASE}/api/v1/chat/${chatId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(
new ChatRequest({
model: model.model,
prompt: message,
...(index !== undefined ? { index } : {}),
...(serializedAttachments !== undefined
? { attachments: serializedAttachments }
: {}),
// Always send web_search as a boolean value (default to false)
web_search: webSearch ?? false,
file_tools: fileTools ?? false,
...(forceUpdate !== undefined ? { forceUpdate } : {}),
...(think !== undefined ? { think } : {}),
}),
),
signal,
});
for await (const event of parseJsonlFromResponse<ChatEventUnion>(response)) {
switch (event.eventName) {
case "download":
yield new DownloadEvent(event);
break;
case "error":
yield new ErrorEvent(event);
break;
default:
yield new ChatEvent(event);
break;
}
}
}
export async function getSettings(): Promise<{
settings: Settings;
}> {
const response = await fetch(`${API_BASE}/api/v1/settings`);
if (!response.ok) {
throw new Error("Failed to fetch settings");
}
const data = await response.json();
return {
settings: new Settings(data.settings),
};
}
export async function updateSettings(settings: Settings): Promise<{
settings: Settings;
}> {
const response = await fetch(`${API_BASE}/api/v1/settings`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(settings),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || "Failed to update settings");
}
const data = await response.json();
return {
settings: new Settings(data.settings),
};
}
export async function renameChat(chatId: string, title: string): Promise<void> {
const response = await fetch(`${API_BASE}/api/v1/chat/${chatId}/rename`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ title: title.trim() }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || "Failed to rename chat");
}
}
export async function deleteChat(chatId: string): Promise<void> {
const response = await fetch(`${API_BASE}/api/v1/chat/${chatId}`, {
method: "DELETE",
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || "Failed to delete chat");
}
}
// Get upstream information for model staleness checking
export async function getModelUpstreamInfo(
model: Model,
): Promise<{ digest?: string; pushTime: number; error?: string }> {
try {
const response = await fetch(`${API_BASE}/api/v1/model/upstream`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
model: model.model,
}),
});
if (!response.ok) {
console.warn(
`Failed to check upstream digest for ${model.model}: ${response.status}`,
);
return { pushTime: 0 };
}
const data = await response.json();
if (data.error) {
console.warn(`Upstream digest check: ${data.error}`);
return { error: data.error, pushTime: 0 };
}
return { digest: data.digest, pushTime: data.pushTime || 0 };
} catch (error) {
console.warn(`Error checking model staleness:`, error);
return { pushTime: 0 };
}
}
export async function* pullModel(
modelName: string,
signal?: AbortSignal,
): AsyncGenerator<{
status: string;
digest?: string;
total?: number;
completed?: number;
done?: boolean;
}> {
const response = await fetch(`${API_BASE}/api/v1/models/pull`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name: modelName }),
signal,
});
if (!response.ok) {
throw new Error(`Failed to pull model: ${response.statusText}`);
}
for await (const event of parseJsonlFromResponse<{
status: string;
digest?: string;
total?: number;
completed?: number;
done?: boolean;
}>(response)) {
yield event;
}
}
export async function getInferenceCompute(): Promise<InferenceCompute[]> {
const response = await fetch(`${API_BASE}/api/v1/inference-compute`);
if (!response.ok) {
throw new Error(
`Failed to fetch inference compute: ${response.statusText}`,
);
}
const data = await response.json();
const inferenceComputeResponse = new InferenceComputeResponse(data);
return inferenceComputeResponse.inferenceComputes || [];
}
export async function fetchHealth(): Promise<boolean> {
try {
const response = await fetch(`${API_BASE}/api/v1/health`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (response.ok) {
const data = await response.json();
return data.healthy || false;
}
return false;
} catch (error) {
console.error("Error checking health:", error);
return false;
}
}
import MessageList from "./MessageList";
import ChatForm from "./ChatForm";
import { FileUpload } from "./FileUpload";
import { DisplayUpgrade } from "./DisplayUpgrade";
import { DisplayStale } from "./DisplayStale";
import { DisplayLogin } from "./DisplayLogin";
import {
useChat,
useSendMessage,
useIsStreaming,
useIsWaitingForLoad,
useDownloadProgress,
useChatError,
useShouldShowStaleDisplay,
useDismissStaleModel,
} from "@/hooks/useChats";
import { useHealth } from "@/hooks/useHealth";
import { useMessageAutoscroll } from "@/hooks/useMessageAutoscroll";
import {
useState,
useEffect,
useLayoutEffect,
useRef,
useCallback,
} from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useSelectedModel } from "@/hooks/useSelectedModel";
import { useUser } from "@/hooks/useUser";
import { useHasVisionCapability } from "@/hooks/useModelCapabilities";
import { Message } from "@/gotypes";
export default function Chat({ chatId }: { chatId: string }) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const chatQuery = useChat(chatId === "new" ? "" : chatId);
const chatErrorQuery = useChatError(chatId === "new" ? "" : chatId);
const { selectedModel } = useSelectedModel(chatId);
const { user } = useUser();
const hasVisionCapability = useHasVisionCapability(selectedModel?.model);
const shouldShowStaleDisplay = useShouldShowStaleDisplay(selectedModel);
const dismissStaleModel = useDismissStaleModel();
const { isHealthy } = useHealth();
const [editingMessage, setEditingMessage] = useState<{
content: string;
index: number;
originalMessage: Message;
} | null>(null);
const prevChatIdRef = useRef<string>(chatId);
const chatFormCallbackRef = useRef<
| ((
files: Array<{ filename: string; data: Uint8Array; type?: string }>,
errors: Array<{ filename: string; error: string }>,
) => void)
| null
>(null);
const handleFilesReceived = useCallback(
(
callback: (
files: Array<{
filename: string;
data: Uint8Array;
type?: string;
}>,
errors: Array<{ filename: string; error: string }>,
) => void,
) => {
chatFormCallbackRef.current = callback;
},
[],
);
const handleFilesProcessed = useCallback(
(
files: Array<{ filename: string; data: Uint8Array; type?: string }>,
errors: Array<{ filename: string; error: string }> = [],
) => {
chatFormCallbackRef.current?.(files, errors);
},
[],
);
const allMessages = chatQuery?.data?.chat?.messages ?? [];
// TODO(parthsareen): will need to consolidate when used with more tools with state
const browserToolResult = chatQuery?.data?.chat?.browser_state;
const chatError = chatErrorQuery.data;
const messages = allMessages;
const isStreaming = useIsStreaming(chatId);
const isWaitingForLoad = useIsWaitingForLoad(chatId);
const downloadProgress = useDownloadProgress(chatId);
const isDownloadingModel = downloadProgress && !downloadProgress.done;
const isDisabled = !isHealthy;
// Clear editing state when navigating to a different chat
useEffect(() => {
setEditingMessage(null);
}, [chatId]);
const sendMessageMutation = useSendMessage(chatId);
const { containerRef, handleNewUserMessage, spacerHeight } =
useMessageAutoscroll({
messages,
isStreaming,
chatId,
});
// Scroll to bottom only when switching to a different existing chat
useLayoutEffect(() => {
// Only scroll if the chatId actually changed (not just messages updating)
if (
prevChatIdRef.current !== chatId &&
containerRef.current &&
messages.length > 0 &&
chatId !== "new"
) {
// Always scroll to the bottom when opening a chat
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
prevChatIdRef.current = chatId;
}, [chatId, messages.length]);
// Simplified submit handler - ChatForm handles all the attachment logic
const handleChatFormSubmit = (
message: string,
options: {
attachments?: Array<{ filename: string; data: Uint8Array }>;
index?: number;
webSearch?: boolean;
fileTools?: boolean;
think?: boolean | string;
},
) => {
// Clear any existing errors when sending a new message
sendMessageMutation.reset();
if (chatError) {
clearChatError();
}
// Prepare attachments for backend
const allAttachments = (options.attachments || []).map((att) => ({
filename: att.filename,
data: att.data.length === 0 ? new Uint8Array(0) : att.data,
}));
sendMessageMutation.mutate({
message,
attachments: allAttachments,
index: editingMessage ? editingMessage.index : options.index,
webSearch: options.webSearch,
fileTools: options.fileTools,
think: options.think,
onChatEvent: (event) => {
if (event.eventName === "chat_created" && event.chatId) {
navigate({
to: "/c/$chatId",
params: {
chatId: event.chatId,
},
});
}
},
});
// Clear edit mode after submission
setEditingMessage(null);
handleNewUserMessage();
};
const handleEditMessage = (content: string, index: number) => {
setEditingMessage({
content,
index,
originalMessage: messages[index],
});
};
const handleCancelEdit = () => {
setEditingMessage(null);
if (chatError) {
clearChatError();
}
};
const clearChatError = () => {
queryClient.setQueryData(
["chatError", chatId === "new" ? "" : chatId],
null,
);
};
const isWindows = navigator.platform.toLowerCase().includes("win");
return chatId === "new" || chatQuery ? (
<FileUpload
onFilesAdded={handleFilesProcessed}
selectedModel={selectedModel}
hasVisionCapability={hasVisionCapability}
>
{chatId === "new" ? (
<div className="flex flex-col h-screen justify-center relative">
<div className="px-6">
<ChatForm
hasMessages={false}
onSubmit={handleChatFormSubmit}
chatId={chatId}
autoFocus={true}
editingMessage={editingMessage}
onCancelEdit={handleCancelEdit}
isDownloadingModel={isDownloadingModel}
isDisabled={isDisabled}
onFilesReceived={handleFilesReceived}
/>
</div>
</div>
) : (
<main className="flex h-screen w-full flex-col relative allow-context-menu select-none">
<section
key={chatId} // This key forces React to recreate the element when chatId changes
ref={containerRef}
className={`flex-1 overflow-y-auto overscroll-contain relative min-h-0 select-none ${isWindows ? "xl:pt-4" : "xl:pt-8"}`}
>
<MessageList
messages={messages}
spacerHeight={spacerHeight}
isWaitingForLoad={isWaitingForLoad}
isStreaming={isStreaming}
downloadProgress={downloadProgress}
onEditMessage={(content: string, index: number) => {
handleEditMessage(content, index);
}}
editingMessageIndex={editingMessage?.index}
error={chatError}
browserToolResult={browserToolResult}
/>
</section>
<div className="flex-shrink-0 sticky bottom-0 z-20">
{selectedModel && shouldShowStaleDisplay && (
<div className="pb-2">
<DisplayStale
model={selectedModel}
onDismiss={() =>
dismissStaleModel(selectedModel?.model || "")
}
chatId={chatId}
onScrollToBottom={() => {
if (containerRef.current) {
containerRef.current.scrollTo({
top: containerRef.current.scrollHeight,
behavior: "smooth",
});
}
}}
/>
</div>
)}
{chatError && chatError.code === "usage_limit_upgrade" && (
<div className="pb-2">
<DisplayUpgrade
error={chatError}
onDismiss={clearChatError}
href={
user?.plan === "pro"
? "https://ollama.com/settings/billing"
: "https://ollama.com/upgrade"
}
/>
</div>
)}
{chatError && chatError.code === "cloud_unauthorized" && (
<div className="pb-2">
<DisplayLogin error={chatError} />
</div>
)}
<ChatForm
hasMessages={messages.length > 0}
onSubmit={handleChatFormSubmit}
chatId={chatId}
autoFocus={true}
editingMessage={editingMessage}
onCancelEdit={handleCancelEdit}
isDisabled={isDisabled}
isDownloadingModel={isDownloadingModel}
onFilesReceived={handleFilesReceived}
/>
</div>
</main>
)}
</FileUpload>
) : (
<div>Loading...</div>
);
}
import Logo from "@/components/Logo";
import { ModelPicker } from "@/components/ModelPicker";
import { WebSearchButton } from "@/components/WebSearchButton";
import { ImageThumbnail } from "@/components/ImageThumbnail";
import { isImageFile } from "@/utils/imageUtils";
import {
useRef,
useState,
useEffect,
useLayoutEffect,
useCallback,
} from "react";
import {
useSendMessage,
useIsStreaming,
useCancelMessage,
} from "@/hooks/useChats";
import { useNavigate } from "@tanstack/react-router";
import { useSelectedModel } from "@/hooks/useSelectedModel";
import { useHasVisionCapability } from "@/hooks/useModelCapabilities";
import { useUser } from "@/hooks/useUser";
import { DisplayLogin } from "@/components/DisplayLogin";
import { ErrorEvent, Message } from "@/gotypes";
import { useSettings } from "@/hooks/useSettings";
import { ThinkButton } from "./ThinkButton";
import { ErrorMessage } from "./ErrorMessage";
import { processFiles } from "@/utils/fileValidation";
import type { ImageData } from "@/types/webview";
import { PlusIcon } from "@heroicons/react/24/outline";
export type ThinkingLevel = "low" | "medium" | "high";
interface FileAttachment {
filename: string;
data: Uint8Array;
type?: string; // MIME type
}
interface MessageInput {
content: string;
attachments: Array<{
id: string;
filename: string;
data?: Uint8Array; // undefined for existing files from editing
}>;
fileErrors: Array<{ filename: string; error: string }>;
}
interface ChatFormProps {
hasMessages: boolean;
onSubmit?: (
message: string,
options: {
attachments?: FileAttachment[];
index?: number;
webSearch?: boolean;
fileTools?: boolean;
think?: boolean | string;
},
) => void;
autoFocus?: boolean;
chatId?: string;
isDownloadingModel?: boolean;
isDisabled?: boolean;
// Editing props - when provided, ChatForm enters edit mode
editingMessage?: {
content: string;
index: number;
originalMessage: Message;
} | null;
onCancelEdit?: () => void;
onFilesReceived?: (
callback: (
files: Array<{ filename: string; data: Uint8Array; type?: string }>,
errors: Array<{ filename: string; error: string }>,
) => void,
) => void;
}
function ChatForm({
hasMessages,
onSubmit,
autoFocus = false,
chatId = "new",
isDownloadingModel = false,
isDisabled = false,
editingMessage,
onCancelEdit,
onFilesReceived,
}: ChatFormProps) {
const [message, setMessage] = useState<MessageInput>({
content: "",
attachments: [],
fileErrors: [],
});
const [isEditing, setIsEditing] = useState<boolean>(false);
const compositionEndTimeoutRef = useRef<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const thinkButtonRef = useRef<HTMLButtonElement>(null);
const thinkingLevelButtonRef = useRef<HTMLButtonElement>(null);
const webSearchButtonRef = useRef<HTMLButtonElement>(null);
const modelPickerRef = useRef<HTMLButtonElement>(null);
const submitButtonRef = useRef<HTMLButtonElement>(null);
const { mutate: sendMessageMutation } = useSendMessage(chatId);
const navigate = useNavigate();
const isStreaming = useIsStreaming(chatId);
const cancelMessage = useCancelMessage();
const isDownloading = isDownloadingModel;
const { selectedModel } = useSelectedModel();
const hasVisionCapability = useHasVisionCapability(selectedModel?.model);
const { isAuthenticated, isLoading: isLoadingUser } = useUser();
const [loginPromptFeature, setLoginPromptFeature] = useState<
"webSearch" | "turbo" | null
>(null);
const [fileUploadError, setFileUploadError] = useState<ErrorEvent | null>(
null,
);
const handleThinkingLevelDropdownToggle = (isOpen: boolean) => {
if (
isOpen &&
modelPickerRef.current &&
(modelPickerRef.current as any).closeDropdown
) {
(modelPickerRef.current as any).closeDropdown();
}
};
const handleModelPickerDropdownToggle = (isOpen: boolean) => {
if (
isOpen &&
thinkingLevelButtonRef.current &&
(thinkingLevelButtonRef.current as any).closeDropdown
) {
(thinkingLevelButtonRef.current as any).closeDropdown();
}
};
const {
settings: {
webSearchEnabled,
airplaneMode,
thinkEnabled,
thinkLevel: settingsThinkLevel,
},
setSettings,
} = useSettings();
// current supported models for web search
const modelLower = selectedModel?.model.toLowerCase() || "";
const supportsWebSearch =
modelLower.startsWith("gpt-oss") ||
modelLower.startsWith("qwen3") ||
modelLower.startsWith("deepseek-v3");
// Use per-chat thinking level instead of global
const thinkLevel: ThinkingLevel =
settingsThinkLevel === "none" || !settingsThinkLevel
? "medium"
: (settingsThinkLevel as ThinkingLevel);
const setThinkingLevel = (newLevel: ThinkingLevel) => {
setSettings({ ThinkLevel: newLevel });
};
const modelSupportsThinkingLevels =
selectedModel?.model.toLowerCase().startsWith("gpt-oss") || false;
const supportsThinkToggling =
selectedModel?.model.toLowerCase().startsWith("deepseek-v3.1") || false;
useEffect(() => {
if (supportsThinkToggling && thinkEnabled && webSearchEnabled) {
setSettings({ WebSearchEnabled: false });
}
}, [
selectedModel?.model,
supportsThinkToggling,
thinkEnabled,
webSearchEnabled,
setSettings,
]);
const removeFile = (index: number) => {
setMessage((prev) => ({
...prev,
attachments: prev.attachments.filter((_, i) => i !== index),
}));
};
const removeFileError = (index: number) => {
setMessage((prev) => ({
...prev,
fileErrors: prev.fileErrors.filter((_, i) => i !== index),
}));
};
// Create stable callback for file handling
const handleFilesReceived = useCallback(
(
files: Array<{ filename: string; data: Uint8Array; type?: string }>,
errors: Array<{ filename: string; error: string }> = [],
) => {
if (files.length > 0) {
setFileUploadError(null);
const newAttachments = files.map((file) => ({
id: crypto.randomUUID(),
filename: file.filename,
data: file.data,
}));
setMessage((prev) => ({
...prev,
attachments: [...prev.attachments, ...newAttachments],
}));
}
// Add validation errors to form state
if (errors.length > 0) {
setMessage((prev) => ({
...prev,
fileErrors: [...prev.fileErrors, ...errors],
}));
}
},
[],
);
useEffect(() => {
if (onFilesReceived) {
onFilesReceived(handleFilesReceived);
}
}, [onFilesReceived, handleFilesReceived]);
// Determine if login banner should be shown
const shouldShowLoginBanner =
!isLoadingUser &&
!isAuthenticated &&
((webSearchEnabled && supportsWebSearch) ||
(selectedModel?.isCloud() && !airplaneMode));
// Determine which feature to highlight in the banner
const getActiveFeatureForBanner = () => {
if (!isAuthenticated) {
if (loginPromptFeature) return loginPromptFeature;
if (webSearchEnabled && selectedModel?.isCloud() && !airplaneMode)
return "webSearch";
if (webSearchEnabled) return "webSearch";
if (selectedModel?.isCloud() && !airplaneMode) return "turbo";
}
return null;
};
const activeFeatureForBanner = getActiveFeatureForBanner();
const resetChatForm = () => {
setMessage({
content: "",
attachments: [],
fileErrors: [],
});
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
}
};
// Clear loginPromptFeature when user becomes authenticated or no features are enabled
useEffect(() => {
if (
isAuthenticated ||
(!webSearchEnabled && !!selectedModel?.isCloud() && !airplaneMode)
) {
setLoginPromptFeature(null);
}
}, [isAuthenticated, webSearchEnabled, selectedModel, airplaneMode]);
// When entering edit mode, populate the composition with existing data
useEffect(() => {
if (!editingMessage) {
// Clear composition and reset textarea height when not editing
resetChatForm();
return;
}
const existingAttachments =
editingMessage.originalMessage?.attachments || [];
setMessage({
content: editingMessage.content,
attachments: existingAttachments.map((att) => ({
id: crypto.randomUUID(),
filename: att.filename,
// No data for existing files - backend will handle them
})),
fileErrors: [],
});
}, [editingMessage]);
// Focus and setup textarea when editing
useLayoutEffect(() => {
if (editingMessage && textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.style.transition =
"height 0.2s ease-out, opacity 0.3s ease-in";
textareaRef.current.style.height = "auto";
textareaRef.current.style.height =
Math.min(textareaRef.current.scrollHeight, 24 * 8) + "px";
}
}, [editingMessage]);
// Clear composition and reset textarea height when chatId changes
useEffect(() => {
resetChatForm();
}, [chatId]);
// Auto-focus textarea when autoFocus is true or when streaming completes (but not when editing)
useEffect(() => {
if ((autoFocus || !isStreaming) && textareaRef.current && !editingMessage) {
const timer = setTimeout(
() => {
textareaRef.current?.focus();
},
autoFocus ? 0 : 100,
);
return () => clearTimeout(timer);
}
}, [autoFocus, isStreaming, editingMessage]);
const focusChatFormInput = () => {
// Focus textarea after model selection or navigation
if (textareaRef.current) {
setTimeout(() => {
textareaRef.current?.focus();
}, 100);
}
};
// Navigation helper function
const navigateToNextElement = useCallback(
(current: HTMLElement, direction: "next" | "prev") => {
const elements = [
textareaRef,
modelSupportsThinkingLevels ? thinkingLevelButtonRef : thinkButtonRef,
webSearchButtonRef,
modelPickerRef,
submitButtonRef,
]
.map((ref) => ref.current)
.filter(Boolean) as HTMLElement[];
const index = elements.indexOf(current);
if (index === -1) return;
const nextIndex =
direction === "next"
? (index + 1) % elements.length
: (index - 1 + elements.length) % elements.length;
elements[nextIndex].focus();
},
[],
);
// Focus textarea when navigating to a chat (when chatId changes)
useEffect(() => {
if (chatId !== "new") {
focusChatFormInput();
}
}, [chatId]);
// Global keyboard and paste event handlers
useEffect(() => {
const focusTextareaIfAppropriate = (target: HTMLElement) => {
if (
!textareaRef.current ||
textareaRef.current === document.activeElement
) {
return;
}
const isEditableTarget =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.contentEditable === "true" ||
target.closest("input") ||
target.closest("textarea") ||
target.closest("[contenteditable='true']");
if (!isEditableTarget) {
textareaRef.current.focus();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
// Handle escape key for canceling
if (e.key === "Escape") {
e.preventDefault();
if (editingMessage && onCancelEdit) {
handleCancelEdit();
} else if (isStreaming) {
handleCancel();
}
return;
}
// Handle Tab navigation between controls
if (e.key === "Tab" && e.target !== textareaRef.current) {
const target = e.target as HTMLElement;
const focusableElements = [
modelSupportsThinkingLevels
? thinkingLevelButtonRef.current
: thinkButtonRef.current,
webSearchButtonRef.current,
modelPickerRef.current,
submitButtonRef.current,
].filter(Boolean) as HTMLElement[];
if (focusableElements.includes(target)) {
e.preventDefault();
if (e.shiftKey) {
navigateToNextElement(target, "prev");
} else {
navigateToNextElement(target, "next");
}
return;
}
}
// Handle paste shortcuts
const isPasteShortcut = (e.ctrlKey || e.metaKey) && e.key === "v";
if (isPasteShortcut) {
focusTextareaIfAppropriate(e.target as HTMLElement);
return;
}
// Handle auto-focus when typing printable characters
const target = e.target as HTMLElement;
const isInInputField =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.contentEditable === "true";
if (
!isInInputField &&
e.key.length === 1 &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
textareaRef.current
) {
textareaRef.current.focus();
}
};
const handlePaste = (e: ClipboardEvent) => {
focusTextareaIfAppropriate(e.target as HTMLElement);
};
window.addEventListener("keydown", handleKeyDown);
document.addEventListener("paste", handlePaste);
return () => {
window.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("paste", handlePaste);
};
}, [isStreaming, editingMessage, onCancelEdit, navigateToNextElement]);
const handleSubmit = async () => {
if (!message.content.trim() || isStreaming || isDownloading) return;
// Check if cloud mode is enabled but user is not authenticated
if (shouldShowLoginBanner) {
return;
}
// Prepare attachments for submission
const attachmentsToSend: FileAttachment[] = message.attachments.map(
(att) => ({
filename: att.filename,
data: att.data || new Uint8Array(0), // Empty data for existing files
}),
);
const useWebSearch = supportsWebSearch && webSearchEnabled && !airplaneMode;
const useThink = modelSupportsThinkingLevels
? thinkLevel
: supportsThinkToggling
? thinkEnabled
: undefined;
if (onSubmit) {
onSubmit(message.content, {
attachments: attachmentsToSend,
index: undefined,
webSearch: useWebSearch,
think: useThink,
});
} else {
sendMessageMutation({
message: message.content,
attachments: attachmentsToSend,
webSearch: useWebSearch,
think: useThink,
onChatEvent: (event) => {
if (event.eventName === "chat_created" && event.chatId) {
navigate({
to: "/c/$chatId",
params: {
chatId: event.chatId,
},
});
}
},
});
}
// Clear composition after successful submission
setMessage({
content: "",
attachments: [],
fileErrors: [],
});
// Reset textarea height and refocus after submit
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.focus();
}
}, 100);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Handle Enter to submit
if (e.key === "Enter" && !e.shiftKey && !isEditing) {
e.preventDefault();
if (!isStreaming && !isDownloading) {
handleSubmit();
}
return;
}
// Handle Tab navigation
if (e.key === "Tab") {
e.preventDefault();
const focusableElements = [
modelSupportsThinkingLevels
? thinkingLevelButtonRef.current
: thinkButtonRef.current,
webSearchButtonRef.current,
modelPickerRef.current,
submitButtonRef.current,
].filter(Boolean);
if (e.shiftKey) {
// Shift+Tab: focus last focusable element
const lastElement = focusableElements[focusableElements.length - 1];
lastElement?.focus();
} else {
// Tab: focus first focusable element
const firstElement = focusableElements[0];
firstElement?.focus();
}
return;
}
};
const handleCompositionStart = () => {
if (compositionEndTimeoutRef.current) {
window.clearTimeout(compositionEndTimeoutRef.current);
}
setIsEditing(true);
};
const handleCompositionEnd = () => {
// Add a small delay to handle the timing issue where Enter keydown
// fires immediately after composition end
compositionEndTimeoutRef.current = window.setTimeout(() => {
setIsEditing(false);
}, 10);
};
const handleCancel = () => {
cancelMessage(chatId);
};
const handleCancelEdit = () => {
// Clear composition and call parent callback
setMessage({
content: "",
attachments: [],
fileErrors: [],
});
onCancelEdit?.();
// Focus the textarea after canceling edit mode
setTimeout(() => {
textareaRef.current?.focus();
}, 0);
};
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
Array.from(files).forEach((file) => {
const reader = new FileReader();
reader.readAsDataURL(file);
});
// Reset file input
if (e.target) {
e.target.value = "";
}
};
// Auto-resize textarea function
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage((prev) => ({ ...prev, content: e.target.value }));
// Reset height to auto to get the correct scrollHeight, then cap at 8 lines
e.target.style.height = "auto";
e.target.style.height = Math.min(e.target.scrollHeight, 24 * 8) + "px";
};
const handleFilesUpload = async () => {
try {
setFileUploadError(null);
const results = await window.webview?.selectMultipleFiles();
if (results && results.length > 0) {
// Convert native dialog results to File objects
const files = results
.map((result: ImageData) => {
if (result.dataURL) {
// Convert dataURL back to File object
const base64Data = result.dataURL.split(",")[1];
const mimeType = result.dataURL.split(";")[0].split(":")[1];
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mimeType });
const file = new File([blob], result.filename, {
type: mimeType,
});
return file;
}
return null;
})
.filter(Boolean) as File[];
if (files.length > 0) {
const { validFiles, errors } = await processFiles(files, {
selectedModel,
hasVisionCapability,
});
// Send processed files and errors to the same handler as FileUpload
if (validFiles.length > 0 || errors.length > 0) {
handleFilesReceived(validFiles, errors);
}
}
}
} catch (error) {
console.error("Error selecting multiple files:", error);
const errorEvent = new ErrorEvent({
eventName: "error" as const,
error:
error instanceof Error ? error.message : "Failed to select files",
code: "file_selection_error",
details:
"An error occurred while trying to open the file selection dialog. Please try again.",
});
setFileUploadError(errorEvent);
}
};
return (
<div className={`pb-3 px-3 ${hasMessages ? "mt-auto" : "my-auto"}`}>
{chatId === "new" && <Logo />}
{shouldShowLoginBanner && (
<DisplayLogin
error={
new ErrorEvent({
eventName: "error",
error:
activeFeatureForBanner === "webSearch"
? "Web search requires authentication"
: "Cloud models require authentication",
code: "cloud_unauthorized",
})
}
message={
activeFeatureForBanner === "webSearch"
? "Web search requires an Ollama account"
: "Cloud models require an Ollama account"
}
className="mb-4"
onDismiss={() => {
// Disable the active features when dismissing
if (webSearchEnabled) setSettings({ WebSearchEnabled: false });
setLoginPromptFeature(null);
}}
/>
)}
{/* File upload error message */}
{fileUploadError && <ErrorMessage error={fileUploadError} />}
<div
className={`relative mx-auto flex bg-neutral-100 w-full max-w-[768px] flex-col items-center rounded-3xl pb-2 pt-4 dark:bg-neutral-800 dark:border-neutral-700 min-h-[88px] transition-opacity duration-200 ${isDisabled ? "opacity-50" : "opacity-100"}`}
>
{isDisabled && (
// overlay to block interaction
<div className="absolute inset-0 z-50 rounded-3xl" />
)}
{editingMessage && (
<div className="w-full px-5 pb-2">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
Press ESC to cancel editing
</p>
</div>
)}
{(message.attachments.length > 0 || message.fileErrors.length > 0) && (
<div className="flex gap-2 overflow-x-auto px-3 pt pb-3 w-full scrollbar-hide">
{message.attachments.map((attachment, index) => (
<div
key={attachment.id}
className="group flex items-center gap-2 py-2 px-3 rounded-lg bg-neutral-50 dark:bg-neutral-700/50 hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors flex-shrink-0"
>
{isImageFile(attachment.filename) ? (
<ImageThumbnail
image={{
filename: attachment.filename,
data: attachment.data || new Uint8Array(0),
}}
className="w-8 h-8 object-cover rounded-md flex-shrink-0"
/>
) : (
<svg
className="w-4 h-4 text-neutral-400 dark:text-neutral-500 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
)}
<span className="text-sm text-neutral-700 dark:text-neutral-300 max-w-[150px] truncate">
{attachment.filename}
</span>
<button
type="button"
onClick={() => removeFile(index)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300 -mr-1 cursor-pointer"
aria-label={`Remove ${attachment.filename}`}
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
))}
{message.fileErrors.map((fileError, index) => (
<div
key={`error-${index}`}
className="group flex items-center gap-2 py-2 px-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 flex-shrink-0"
>
<svg
className="w-4 h-4 text-red-500 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span className="text-sm text-red-700 dark:text-red-300 max-w-[100px] truncate">
{fileError.filename}
</span>
<span className="text-xs text-red-600 dark:text-red-400 opacity-75">
{fileError.error}
</span>
<button
type="button"
onClick={() => removeFileError(index)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-300 -mr-1 ml-auto"
aria-label={`Remove ${fileError.filename}`}
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
))}
</div>
)}
<div className="relative w-full px-5">
<textarea
ref={textareaRef}
value={message.content}
onChange={handleTextareaChange}
placeholder="Send a message"
disabled={isDisabled}
className={`allow-context-menu w-full overflow-y-auto text-neutral-700 outline-none resize-none border-none bg-transparent dark:text-white placeholder:text-neutral-400 dark:placeholder:text-neutral-500 min-h-[24px] leading-6 transition-opacity duration-300 ${
editingMessage ? "animate-fade-in" : ""
}`}
rows={1}
onKeyDown={handleKeyDown}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
/>
</div>
{/* Controls */}
<div className="flex w-full items-center justify-end gap-2 px-3 pt-2">
{/* Tool buttons - animate from underneath model picker */}
{!isDisabled && (
<div className="flex-1 flex justify-end items-center gap-2">
<div className={`flex gap-2`}>
{/* File Upload Buttons */}
<button
type="button"
onClick={handleFilesUpload}
className="flex h-9 w-9 items-center justify-center rounded-full bg-white dark:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer border border-transparent"
title="Upload multiple files"
>
<PlusIcon className="w-4.5 h-4.5 stroke-2 text-neutral-500 dark:text-neutral-400" />
</button>
{/* Thinking Level Button */}
{modelSupportsThinkingLevels && (
<>
<ThinkButton
mode="thinkingLevel"
ref={thinkingLevelButtonRef}
isVisible={modelSupportsThinkingLevels}
currentLevel={thinkLevel}
onLevelChange={setThinkingLevel}
onDropdownToggle={handleThinkingLevelDropdownToggle}
/>
</>
)}
{/* Think Button turn on and off */}
{supportsThinkToggling && !modelSupportsThinkingLevels && (
<>
<ThinkButton
mode="think"
ref={thinkButtonRef}
isVisible={
supportsThinkToggling && !modelSupportsThinkingLevels
}
isActive={thinkEnabled}
onToggle={() => {
// DeepSeek-v3 specific - thinking and web search are mutually exclusive
if (supportsThinkToggling) {
const enable = !thinkEnabled;
setSettings({
ThinkEnabled: enable,
...(enable ? { WebSearchEnabled: false } : {}),
});
return;
}
setSettings({ ThinkEnabled: !thinkEnabled });
}}
/>
</>
)}
<WebSearchButton
ref={webSearchButtonRef}
isVisible={supportsWebSearch && airplaneMode === false}
isActive={webSearchEnabled}
onToggle={() => {
if (!webSearchEnabled && !isAuthenticated) {
setLoginPromptFeature("webSearch");
}
const enable = !webSearchEnabled;
if (supportsThinkToggling && enable) {
setSettings({
WebSearchEnabled: true,
ThinkEnabled: false,
});
return;
}
setSettings({ WebSearchEnabled: enable });
}}
/>
</div>
</div>
)}
{/* Model picker and submit button */}
<div className="flex items-center gap-2 relative z-20">
<ModelPicker
ref={modelPickerRef}
chatId={chatId}
onModelSelect={focusChatFormInput}
onEscape={focusChatFormInput}
isDisabled={isDisabled}
onDropdownToggle={handleModelPickerDropdownToggle}
/>
<button
ref={submitButtonRef}
onClick={
isStreaming || isDownloading ? handleCancel : handleSubmit
}
disabled={
!isStreaming &&
!isDownloading &&
(!message.content.trim() ||
shouldShowLoginBanner ||
message.fileErrors.length > 0)
}
className={`flex items-center justify-center h-9 w-9 rounded-full disabled:cursor-default cursor-pointer bg-black text-white dark:bg-white dark:text-black disabled:opacity-10 focus:outline-none focus:ring-2 focus:ring-blue-500`}
>
{isStreaming || isDownloading ? (
<svg
className="h-3 w-3 fill-current"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 12.3838C0 13.6055 0.738281 14.3262 1.96875 14.3262H12.3486C13.5879 14.3262 14.3174 13.6055 14.3174 12.3838V1.94238C14.3174 0.720703 13.5879 0 12.3486 0H1.96875C0.738281 0 0 0.720703 0 1.94238V12.3838Z" />
</svg>
) : (
<svg
className="h-3.5 w-3.5 fill-current"
viewBox="0 0 14 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0.918802 7.73542C1.19144 7.73542 1.43401 7.63188 1.60065 7.45804L3.59348 5.48929L6.7957 1.89614L10.0107 5.48929L12.0067 7.45804C12.179 7.63188 12.416 7.73542 12.6886 7.73542C13.2182 7.73542 13.6074 7.33974 13.6074 6.80466C13.6074 6.54785 13.5149 6.3174 13.3131 6.10998L7.51833 0.306385C7.32603 0.106874 7.06851 0 6.8029 0C6.5373 0 6.2782 0.106874 6.08748 0.306385L0.299881 6.10998C0.0996671 6.3174 0 6.54785 0 6.80466C0 7.33974 0.389177 7.73542 0.918802 7.73542ZM6.8029 16.6848C7.36909 16.6848 7.76073 16.2909 7.76073 15.7136V4.79494L7.65544 1.93059C7.65544 1.40993 7.31091 1.06066 6.8029 1.06066C6.29332 1.06066 5.94879 1.40993 5.94879 1.93059L5.8435 4.79494V15.7136C5.8435 16.2909 6.23672 16.6848 6.8029 16.6848Z" />
</svg>
)}
</button>
</div>
</div>
</div>
{/* Hidden file input for fallback */}
<input
ref={fileInputRef}
type="file"
accept="*/*"
multiple
onChange={handleFileInputChange}
className="hidden"
/>
</div>
);
}
export default ChatForm;
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment