Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
411a8cebc7 | ||
|
|
14191de8f4 | ||
|
|
4ef48fad82 | ||
|
|
21beab26f1 | ||
|
|
050a46d7a8 | ||
|
|
3217c3f66e |
+2
-2
@@ -31,7 +31,7 @@ Chat upload limits:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"providers": {
|
"providers": {
|
||||||
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
|
"openai": { "models": ["chat-latest", "gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
|
||||||
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
|
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
|
||||||
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
|
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
|
||||||
"gemini": { "models": ["gemini-3.5-flash"], "loadedAt": null, "error": null },
|
"gemini": { "models": ["gemini-3.5-flash"], "loadedAt": null, "error": null },
|
||||||
@@ -39,7 +39,7 @@ Chat upload limits:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
|
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation, including the exact `chat-latest` alias when returned by OpenAI. Audio, image-generation, embedding, moderation, and other specialized model ids remain excluded. See [OpenAI's Chat Latest documentation](https://developers.openai.com/api/docs/models/chat-latest) for supported capabilities.
|
||||||
- Gemini model lists are loaded from Google's native Models API and filtered to Gemini `generateContent` model ids.
|
- Gemini model lists are loaded from Google's native Models API and filtered to Gemini `generateContent` model ids.
|
||||||
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
|
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
|
||||||
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
|
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ Request body:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Behavior notes:
|
Behavior notes:
|
||||||
|
- `provider` and `model` are required. Web and native clients default Quick Question to `provider: "openai"` and `model: "chat-latest"` when no Quick Question selection has been saved. Saved Quick Question choices take precedence over this default and are separate from regular chat preferences.
|
||||||
- `question` is required, trimmed by the server, and must not be empty.
|
- `question` is required, trimmed by the server, and must not be empty.
|
||||||
- The server prepends a Quick Question system prompt that asks for a succinct, direct, self-contained answer without follow-up questions. Clients do not send or maintain this prompt.
|
- The server prepends a Quick Question system prompt that asks for a succinct, direct, self-contained answer without follow-up questions. Clients do not send or maintain this prompt.
|
||||||
- Quick Questions are always non-persistent. The endpoint does not create a chat or store messages, tool-call logs, assistant output, or `LlmCall` metadata.
|
- Quick Questions are always non-persistent. The endpoint does not create a chat or store messages, tool-call logs, assistant output, or `LlmCall` metadata.
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ final class SybilSettingsStore {
|
|||||||
self.preferredModelByProvider = preferredModels
|
self.preferredModelByProvider = preferredModels
|
||||||
|
|
||||||
self.quickQuestionPreferredProvider =
|
self.quickQuestionPreferredProvider =
|
||||||
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? provider
|
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
|
||||||
self.quickQuestionPreferredModelByProvider = [
|
self.quickQuestionPreferredModelByProvider = [
|
||||||
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? preferredModels[.openai] ?? "gpt-4.1-mini",
|
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? "chat-latest",
|
||||||
.anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest",
|
.anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest",
|
||||||
.xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini",
|
.xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini",
|
||||||
.gemini: defaults.string(forKey: Keys.quickQuestionPreferredGeminiModel) ?? preferredModels[.gemini] ?? "gemini-3.5-flash",
|
.gemini: defaults.string(forKey: Keys.quickQuestionPreferredGeminiModel) ?? preferredModels[.gemini] ?? "gemini-3.5-flash",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct SybilSidebarView: View {
|
struct SybilSidebarView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
@@ -169,7 +170,7 @@ struct SybilSidebarItemList: View {
|
|||||||
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
|
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.kind == .chat {
|
if case let .chat(chatID) = item.selection {
|
||||||
Button {
|
Button {
|
||||||
forkChat(item)
|
forkChat(item)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -183,6 +184,12 @@ struct SybilSidebarItemList: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Rename", systemImage: "pencil")
|
Label("Rename", systemImage: "pencil")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
UIPasteboard.general.string = chatID
|
||||||
|
} label: {
|
||||||
|
Label("Copy Session ID", systemImage: "doc.on.doc")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ final class SybilViewModel {
|
|||||||
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
|
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
|
||||||
|
|
||||||
private let fallbackModels: [Provider: [String]] = [
|
private let fallbackModels: [Provider: [String]] = [
|
||||||
.openai: ["gpt-4.1-mini"],
|
.openai: ["gpt-4.1-mini", "chat-latest"],
|
||||||
.anthropic: ["claude-3-5-sonnet-latest"],
|
.anthropic: ["claude-3-5-sonnet-latest"],
|
||||||
.xai: ["grok-3-mini"],
|
.xai: ["grok-3-mini"],
|
||||||
.gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
.gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
private let updateChatStarResponses: [String: ChatSummary]
|
private let updateChatStarResponses: [String: ChatSummary]
|
||||||
private let updateSearchStarResponses: [String: SearchSummary]
|
private let updateSearchStarResponses: [String: SearchSummary]
|
||||||
private let activeRunsResponse: ActiveRunsResponse
|
private let activeRunsResponse: ActiveRunsResponse
|
||||||
|
private let modelCatalogResponse: ModelCatalogResponse
|
||||||
|
|
||||||
private var snapshot = MockClientCallSnapshot()
|
private var snapshot = MockClientCallSnapshot()
|
||||||
private var lastCreateChatCall: ChatCreateCallSnapshot?
|
private var lastCreateChatCall: ChatCreateCallSnapshot?
|
||||||
@@ -84,7 +85,8 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
updateChatStarResponses: [String: ChatSummary] = [:],
|
updateChatStarResponses: [String: ChatSummary] = [:],
|
||||||
updateSearchStarResponses: [String: SearchSummary] = [:],
|
updateSearchStarResponses: [String: SearchSummary] = [:],
|
||||||
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
|
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
|
||||||
workspaceItemsResponse: [WorkspaceItem]? = nil
|
workspaceItemsResponse: [WorkspaceItem]? = nil,
|
||||||
|
modelCatalogResponse: ModelCatalogResponse = ModelCatalogResponse(providers: [:])
|
||||||
) {
|
) {
|
||||||
self.chatsResponse = chatsResponse
|
self.chatsResponse = chatsResponse
|
||||||
self.searchesResponse = searchesResponse
|
self.searchesResponse = searchesResponse
|
||||||
@@ -98,6 +100,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
self.updateChatStarResponses = updateChatStarResponses
|
self.updateChatStarResponses = updateChatStarResponses
|
||||||
self.updateSearchStarResponses = updateSearchStarResponses
|
self.updateSearchStarResponses = updateSearchStarResponses
|
||||||
self.activeRunsResponse = activeRunsResponse
|
self.activeRunsResponse = activeRunsResponse
|
||||||
|
self.modelCatalogResponse = modelCatalogResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func makeWorkspaceItems(chats: [ChatSummary], searches: [SearchSummary]) -> [WorkspaceItem] {
|
private static func makeWorkspaceItems(chats: [ChatSummary], searches: [SearchSummary]) -> [WorkspaceItem] {
|
||||||
@@ -306,7 +309,7 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func listModels() async throws -> ModelCatalogResponse {
|
func listModels() async throws -> ModelCatalogResponse {
|
||||||
ModelCatalogResponse(providers: [:])
|
modelCatalogResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
func getActiveRuns() async throws -> ActiveRunsResponse {
|
func getActiveRuns() async throws -> ActiveRunsResponse {
|
||||||
@@ -1257,6 +1260,7 @@ private func makeCompletionToolCall(
|
|||||||
#expect(snapshot.runQuickQuestionStream == 1)
|
#expect(snapshot.runQuickQuestionStream == 1)
|
||||||
#expect(snapshot.runCompletionStream == 0)
|
#expect(snapshot.runCompletionStream == 0)
|
||||||
#expect(body?.provider == .openai)
|
#expect(body?.provider == .openai)
|
||||||
|
#expect(body?.model == "chat-latest")
|
||||||
#expect(body?.question == "How do I reset my password?")
|
#expect(body?.question == "How do I reset my password?")
|
||||||
#expect(viewModel.quickQuestionAnswerText == "Reset it from Settings.")
|
#expect(viewModel.quickQuestionAnswerText == "Reset it from Settings.")
|
||||||
#expect(!viewModel.isQuickQuestionSending)
|
#expect(!viewModel.isQuickQuestionSending)
|
||||||
@@ -1370,6 +1374,42 @@ private func makeCompletionToolCall(
|
|||||||
#expect(viewModel.quickQuestionPrompt.isEmpty)
|
#expect(viewModel.quickQuestionPrompt.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func quickQuestionDefaultsToChatLatestIndependentlyOfRegularChatPreferences() async {
|
||||||
|
let defaults = UserDefaults(suiteName: #function)!
|
||||||
|
defaults.removePersistentDomain(forName: #function)
|
||||||
|
defer { defaults.removePersistentDomain(forName: #function) }
|
||||||
|
defaults.set("anthropic", forKey: "sybil.ios.preferredProvider")
|
||||||
|
defaults.set("gpt-4o", forKey: "sybil.ios.preferredOpenAIModel")
|
||||||
|
let settings = SybilSettingsStore(defaults: defaults)
|
||||||
|
let client = MockSybilClient(modelCatalogResponse: ModelCatalogResponse(providers: [
|
||||||
|
.openai: ProviderModelInfo(models: ["gpt-4.1-mini", "chat-latest"], loadedAt: nil, error: nil),
|
||||||
|
.anthropic: ProviderModelInfo(models: ["claude-3-5-sonnet-latest"], loadedAt: nil, error: nil)
|
||||||
|
]))
|
||||||
|
let viewModel = SybilViewModel(settings: settings) { _ in client }
|
||||||
|
|
||||||
|
#expect(viewModel.provider == .anthropic)
|
||||||
|
#expect(settings.preferredModelByProvider[.openai] == "gpt-4o")
|
||||||
|
#expect(viewModel.quickQuestionProvider == .openai)
|
||||||
|
#expect(viewModel.quickQuestionModel == "chat-latest")
|
||||||
|
#expect(viewModel.quickQuestionProviderModelOptions.contains("chat-latest"))
|
||||||
|
|
||||||
|
viewModel.setQuickQuestionProvider(.anthropic)
|
||||||
|
viewModel.setQuickQuestionProvider(.openai)
|
||||||
|
#expect(viewModel.quickQuestionModel == "chat-latest")
|
||||||
|
|
||||||
|
await viewModel.bootstrap()
|
||||||
|
#expect(viewModel.quickQuestionModel == "chat-latest")
|
||||||
|
#expect(viewModel.quickQuestionProviderModelOptions == ["gpt-4.1-mini", "chat-latest"])
|
||||||
|
|
||||||
|
viewModel.setQuickQuestionProvider(.anthropic)
|
||||||
|
viewModel.setQuickQuestionProvider(.openai)
|
||||||
|
#expect(viewModel.quickQuestionModel == "chat-latest")
|
||||||
|
#expect(SybilSettingsStore(defaults: defaults).quickQuestionPreferredModelByProvider[.openai] == "chat-latest")
|
||||||
|
#expect(settings.preferredProvider == .anthropic)
|
||||||
|
#expect(settings.preferredModelByProvider[.openai] == "gpt-4o")
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func quickQuestionProviderAndModelSelectionPersistSeparately() async throws {
|
@Test func quickQuestionProviderAndModelSelectionPersistSeparately() async throws {
|
||||||
let defaults = UserDefaults(suiteName: #function)!
|
let defaults = UserDefaults(suiteName: #function)!
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ second **Option+Space**, the close button, or clicking outside dismisses it.
|
|||||||
Hiding the panel preserves the draft and answer and lets an in-flight answer finish.
|
Hiding the panel preserves the draft and answer and lets an in-flight answer finish.
|
||||||
**Open in Chat** saves the question and answer in the regular workspace.
|
**Open in Chat** saves the question and answer in the regular workspace.
|
||||||
|
|
||||||
|
Quick Question defaults to OpenAI `chat-latest` on iPhone, iPad, and Mac. Its saved
|
||||||
|
provider/model selection takes precedence and is separate from regular chat preferences.
|
||||||
|
|
||||||
The regular Mac chat/search composer uses the same Return/Shift+Return rules.
|
The regular Mac chat/search composer uses the same Return/Shift+Return rules.
|
||||||
Holding Return does not repeatedly submit. On iOS, Return submits from Quick
|
Holding Return does not repeatedly submit. On iOS, Return submits from Quick
|
||||||
Question, while the normal chat/search composer continues to use Return for a
|
Question, while the normal chat/search composer continues to use Return for a
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ function isLikelyResponsesApiModel(model: string) {
|
|||||||
if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false;
|
if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false;
|
||||||
if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false;
|
if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false;
|
||||||
if (id.includes("search") || id.includes("computer-use")) return false;
|
if (id.includes("search") || id.includes("computer-use")) return false;
|
||||||
return /^(gpt-|o\d|chatgpt-)/.test(id);
|
return id === "chat-latest" || /^(gpt-|o\d|chatgpt-)/.test(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLikelyGeminiChatModel(model: string) {
|
function isLikelyGeminiChatModel(model: string) {
|
||||||
|
|||||||
@@ -1,6 +1,50 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { describeProviderChatBackend } from "../src/llm/provider-adapters.js";
|
import { env } from "../src/env.js";
|
||||||
|
import { describeProviderChatBackend, fetchProviderCatalogModels } from "../src/llm/provider-adapters.js";
|
||||||
|
|
||||||
|
for (const { name, listedModels, expectedModels } of [
|
||||||
|
{
|
||||||
|
name: "OpenAI catalog includes chat-latest while excluding non-chat models",
|
||||||
|
listedModels: [
|
||||||
|
"gpt-4.1-mini",
|
||||||
|
"chat-latest",
|
||||||
|
"o3",
|
||||||
|
"chatgpt-4o-latest",
|
||||||
|
"chat-latest",
|
||||||
|
"text-embedding-3-small",
|
||||||
|
"omni-moderation-latest",
|
||||||
|
"gpt-4o-audio-preview",
|
||||||
|
"gpt-realtime",
|
||||||
|
"gpt-4o-transcribe",
|
||||||
|
"gpt-4o-mini-tts",
|
||||||
|
"gpt-image-1",
|
||||||
|
"chatgpt-image-latest",
|
||||||
|
"gpt-4o-search-preview",
|
||||||
|
"computer-use-preview",
|
||||||
|
"sora-2",
|
||||||
|
],
|
||||||
|
expectedModels: ["chat-latest", "chatgpt-4o-latest", "gpt-4.1-mini", "o3"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "OpenAI catalog does not invent models absent from the provider response",
|
||||||
|
listedModels: ["gpt-4.1-mini"],
|
||||||
|
expectedModels: ["gpt-4.1-mini"],
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
test(name, async (t) => {
|
||||||
|
const originalKey = env.OPENAI_API_KEY;
|
||||||
|
env.OPENAI_API_KEY = "test-openai-key";
|
||||||
|
t.after(() => { env.OPENAI_API_KEY = originalKey; });
|
||||||
|
t.mock.method(globalThis, "fetch", async (input: RequestInfo | URL) => {
|
||||||
|
const url = input instanceof Request ? input.url : String(input);
|
||||||
|
assert.equal(url, "https://api.openai.com/v1/models");
|
||||||
|
return Response.json({ data: listedModels.map((id) => ({ id })) });
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(await fetchProviderCatalogModels("openai"), expectedModels);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test("provider backend registry selects chat protocol and managed-tool mode", () => {
|
test("provider backend registry selects chat protocol and managed-tool mode", () => {
|
||||||
assert.deepEqual(describeProviderChatBackend("openai", []), {
|
assert.deepEqual(describeProviderChatBackend("openai", []), {
|
||||||
|
|||||||
+79
-54
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||||
import type { TargetedTouchEvent } from "preact";
|
import type { TargetedTouchEvent } from "preact";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
@@ -80,7 +80,7 @@ import {
|
|||||||
resolveSidebarSelectionAfterRefresh,
|
resolveSidebarSelectionAfterRefresh,
|
||||||
type SidebarSelection,
|
type SidebarSelection,
|
||||||
} from "@/lib/sidebar-selection";
|
} from "@/lib/sidebar-selection";
|
||||||
import { buildQuickQuestionRequest } from "@/lib/quick-question";
|
import { buildQuickQuestionRequest, pickQuickQuestionModel, QUICK_QUESTION_DEFAULT_MODEL } from "@/lib/quick-question";
|
||||||
import {
|
import {
|
||||||
appendStreamingDelta,
|
appendStreamingDelta,
|
||||||
clearStreamingAssistantTrace,
|
clearStreamingAssistantTrace,
|
||||||
@@ -161,7 +161,7 @@ function buildWorkspaceUrl(selection: SidebarSelection | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
|
||||||
openai: ["gpt-4.1-mini"],
|
openai: ["gpt-4.1-mini", QUICK_QUESTION_DEFAULT_MODEL],
|
||||||
anthropic: ["claude-3-5-sonnet-latest"],
|
anthropic: ["claude-3-5-sonnet-latest"],
|
||||||
xai: ["grok-3-mini"],
|
xai: ["grok-3-mini"],
|
||||||
gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
|
||||||
@@ -654,9 +654,10 @@ type ModelComboboxProps = {
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ModelCombobox({ options, value, onChange, disabled = false }: ModelComboboxProps) {
|
function ModelCombobox({ options, value, onChange, disabled = false, className }: ModelComboboxProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [draftValue, setDraftValue] = useState(value);
|
const [draftValue, setDraftValue] = useState(value);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -710,7 +711,12 @@ function ModelCombobox({ options, value, onChange, disabled = false }: ModelComb
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={rootRef}>
|
<div className="relative" ref={rootRef}>
|
||||||
<div className="flex h-10 min-w-56 items-center rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)]">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 min-w-56 items-center rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={draftValue}
|
value={draftValue}
|
||||||
@@ -948,7 +954,6 @@ export default function App() {
|
|||||||
isCheckingSession,
|
isCheckingSession,
|
||||||
isSigningIn,
|
isSigningIn,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
authMode,
|
|
||||||
authError,
|
authError,
|
||||||
handleAuthFailure: baseHandleAuthFailure,
|
handleAuthFailure: baseHandleAuthFailure,
|
||||||
handleSignIn,
|
handleSignIn,
|
||||||
@@ -986,7 +991,7 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
const [quickModel, setQuickModel] = useState(() => {
|
const [quickModel, setQuickModel] = useState(() => {
|
||||||
const stored = loadStoredQuickQuestionModelSelection();
|
const stored = loadStoredQuickQuestionModelSelection();
|
||||||
return stored.modelPreferences[stored.provider] ?? PROVIDER_FALLBACK_MODELS[stored.provider][0];
|
return pickQuickQuestionModel(stored.provider, PROVIDER_FALLBACK_MODELS[stored.provider], stored.modelPreferences[stored.provider]);
|
||||||
});
|
});
|
||||||
const [isQuickQuestionOpen, setIsQuickQuestionOpen] = useState(false);
|
const [isQuickQuestionOpen, setIsQuickQuestionOpen] = useState(false);
|
||||||
const [quickPrompt, setQuickPrompt] = useState("");
|
const [quickPrompt, setQuickPrompt] = useState("");
|
||||||
@@ -1018,6 +1023,7 @@ export default function App() {
|
|||||||
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const renameChatInputRef = useRef<HTMLInputElement>(null);
|
const renameChatInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const chatSettingsDialogRef = useRef<HTMLDialogElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const dragDepthRef = useRef(0);
|
const dragDepthRef = useRef(0);
|
||||||
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
|
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
|
||||||
@@ -1443,7 +1449,7 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (quickModel.trim()) return;
|
if (quickModel.trim()) return;
|
||||||
setQuickModel((current) => {
|
setQuickModel((current) => {
|
||||||
return current.trim() || pickProviderModel(quickProviderModelOptions, quickProviderModelPreferences[quickProvider]);
|
return current.trim() || pickQuickQuestionModel(quickProvider, quickProviderModelOptions, quickProviderModelPreferences[quickProvider]);
|
||||||
});
|
});
|
||||||
}, [quickModel, quickProvider, quickProviderModelOptions, quickProviderModelPreferences]);
|
}, [quickModel, quickProvider, quickProviderModelOptions, quickProviderModelPreferences]);
|
||||||
|
|
||||||
@@ -1930,6 +1936,7 @@ export default function App() {
|
|||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (chatSettingsDialogRef.current?.open) return;
|
||||||
const hasPrimaryModifier = event.metaKey || event.ctrlKey;
|
const hasPrimaryModifier = event.metaKey || event.ctrlKey;
|
||||||
if (!hasPrimaryModifier || event.altKey) return;
|
if (!hasPrimaryModifier || event.altKey) return;
|
||||||
|
|
||||||
@@ -2331,16 +2338,16 @@ export default function App() {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [renameChatDialog]);
|
}, [renameChatDialog]);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isChatSettingsOpen) return;
|
if (!isChatSettingsOpen) return;
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const dialog = chatSettingsDialogRef.current;
|
||||||
if (event.key !== "Escape" || isSavingChatSettings) return;
|
const previouslyFocused = document.activeElement;
|
||||||
event.preventDefault();
|
dialog?.showModal();
|
||||||
setIsChatSettingsOpen(false);
|
return () => {
|
||||||
|
dialog?.close();
|
||||||
|
if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus({ preventScroll: true });
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
}, [isChatSettingsOpen]);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [isChatSettingsOpen, isSavingChatSettings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isQuickQuestionOpen) return;
|
if (!isQuickQuestionOpen) return;
|
||||||
@@ -3433,7 +3440,7 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-grid-surface app-safe-frame h-full">
|
<div className="app-grid-surface app-safe-frame h-full">
|
||||||
<div className="workspace-shell flex h-full w-full overflow-hidden bg-transparent md:gap-2">
|
<div className="workspace-shell flex h-full w-full overflow-hidden bg-transparent">
|
||||||
{isMobileSidebarOpen ? (
|
{isMobileSidebarOpen ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -3445,20 +3452,14 @@ export default function App() {
|
|||||||
|
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"workspace-sidebar glass-panel fixed inset-y-0 left-0 z-40 flex w-[86vw] max-w-80 shrink-0 flex-col border-r border-violet-300/18 transition-transform md:static md:z-auto md:w-80 md:max-w-none md:rounded-2xl md:border",
|
"workspace-sidebar glass-panel fixed inset-y-0 left-0 z-40 flex w-[86vw] max-w-80 shrink-0 flex-col border-r border-violet-300/18 transition-transform md:static md:z-auto md:w-80 md:max-w-none",
|
||||||
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="relative min-h-24 px-4 pb-4 pt-5">
|
<div className="relative min-h-24 px-4 pb-4 pt-5 flex items-center">
|
||||||
<div className="pr-24">
|
|
||||||
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
|
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
|
||||||
SYBIL
|
SYBIL
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
|
||||||
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SybilCharacter
|
<SybilCharacter
|
||||||
className="absolute right-4 top-3 h-[calc(100%-1.5rem)]"
|
className="absolute right-4 top-3 h-[calc(100%-1.5rem)]"
|
||||||
isBusy={isActiveSelectionSending || isQuickQuestionSending}
|
isBusy={isActiveSelectionSending || isQuickQuestionSending}
|
||||||
@@ -3608,7 +3609,7 @@ export default function App() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
className="workspace-content glass-panel relative flex min-w-0 flex-1 touch-pan-y flex-col overflow-hidden border-violet-300/18 md:touch-auto md:rounded-2xl md:border"
|
className="workspace-content glass-panel relative flex min-w-0 flex-1 touch-pan-y flex-col overflow-hidden md:touch-auto"
|
||||||
onTouchStart={handleMobileWorkspaceTouchStart}
|
onTouchStart={handleMobileWorkspaceTouchStart}
|
||||||
onTouchMove={handleMobileWorkspaceTouchMove}
|
onTouchMove={handleMobileWorkspaceTouchMove}
|
||||||
onTouchEnd={handleMobileWorkspaceTouchEnd}
|
onTouchEnd={handleMobileWorkspaceTouchEnd}
|
||||||
@@ -3822,44 +3823,66 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isChatSettingsOpen ? (
|
{isChatSettingsOpen ? (
|
||||||
<div
|
<dialog
|
||||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
ref={chatSettingsDialogRef}
|
||||||
|
aria-labelledby="chat-settings-title"
|
||||||
|
className="fixed inset-0 m-0 flex h-dvh max-h-none w-full max-w-none items-center justify-center overflow-hidden bg-transparent p-0 text-foreground backdrop:bg-black/70 backdrop:backdrop-blur-md md:p-6"
|
||||||
|
onCancel={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!isSavingChatSettings) setIsChatSettingsOpen(false);
|
||||||
|
}}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (event.target === event.currentTarget && !isSavingChatSettings) setIsChatSettingsOpen(false);
|
if (event.target === event.currentTarget && !isSavingChatSettings) setIsChatSettingsOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
role="dialog"
|
className="glass-panel flex h-full min-h-0 w-full flex-col overflow-hidden md:h-auto md:max-h-full md:max-w-2xl md:rounded-2xl md:border md:border-violet-300/24"
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="chat-settings-title"
|
|
||||||
className="glass-panel flex max-h-[88vh] w-full max-w-2xl flex-col rounded-2xl border border-violet-300/24 p-4 shadow-2xl shadow-black/45 md:p-5"
|
|
||||||
onSubmit={(event) => void handleChatSettingsSubmit(event)}
|
onSubmit={(event) => void handleChatSettingsSubmit(event)}
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
<div className="chat-settings-header grid shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2 border-b border-violet-300/16 md:flex md:justify-between md:gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-11 justify-self-start px-2 text-violet-200 hover:bg-violet-400/10 hover:text-violet-100 md:hidden"
|
||||||
|
onClick={() => setIsChatSettingsOpen(false)}
|
||||||
|
disabled={isSavingChatSettings}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 id="chat-settings-title" className="text-sm font-semibold text-violet-50">
|
<h2 id="chat-settings-title" className="text-base font-semibold text-violet-50 md:text-sm">
|
||||||
Chat settings
|
Chat settings
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 truncate text-xs text-muted-foreground">{chatSettingsTitleDraft.trim() || "New chat"}</p>
|
<p className="mt-1 hidden truncate text-xs text-muted-foreground md:block">{chatSettingsTitleDraft.trim() || "New chat"}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-8 w-8"
|
className="hidden h-8 w-8 shrink-0 md:inline-flex"
|
||||||
onClick={() => setIsChatSettingsOpen(false)}
|
onClick={() => setIsChatSettingsOpen(false)}
|
||||||
disabled={isSavingChatSettings}
|
disabled={isSavingChatSettings}
|
||||||
aria-label="Close chat settings"
|
aria-label="Close chat settings"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-11 justify-self-end gap-1.5 px-2 font-semibold text-violet-200 hover:bg-violet-400/10 hover:text-violet-100 md:hidden"
|
||||||
|
disabled={isSavingChatSettings}
|
||||||
|
>
|
||||||
|
{isSavingChatSettings ? <LoaderCircle className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
|
<div className="chat-settings-content min-h-0 flex-1 space-y-5 overflow-y-auto overscroll-contain md:space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Chat title</span>
|
<label for="chat-settings-chat-title" className="mb-1.5 block text-xs font-semibold text-violet-100/72">Chat title</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
id="chat-settings-chat-title"
|
||||||
value={chatSettingsTitleDraft}
|
value={chatSettingsTitleDraft}
|
||||||
onInput={(event) => {
|
onInput={(event) => {
|
||||||
setChatSettingsTitleDraft(event.currentTarget.value);
|
setChatSettingsTitleDraft(event.currentTarget.value);
|
||||||
@@ -3867,7 +3890,7 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
maxLength={120}
|
maxLength={120}
|
||||||
placeholder={draftKind === null && selectedItem?.kind === "chat" ? "Chat title" : "Optional title"}
|
placeholder={draftKind === null && selectedItem?.kind === "chat" ? "Chat title" : "Optional title"}
|
||||||
className="h-11 min-w-0 flex-1 rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
className="h-11 min-w-0 flex-1 rounded-lg border border-violet-300/22 bg-background/70 px-3 text-base text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70 md:text-sm"
|
||||||
disabled={isSavingChatSettings}
|
disabled={isSavingChatSettings}
|
||||||
/>
|
/>
|
||||||
{chatSettingsChatId ? (
|
{chatSettingsChatId ? (
|
||||||
@@ -3895,7 +3918,7 @@ export default function App() {
|
|||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Provider</span>
|
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Provider</span>
|
||||||
<select
|
<select
|
||||||
className="h-10 w-full rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
className="h-11 w-full rounded-lg border border-violet-300/22 bg-background/70 px-3 text-base text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70 md:h-10 md:text-sm"
|
||||||
value={chatSettingsProviderDraft}
|
value={chatSettingsProviderDraft}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const nextProvider = event.currentTarget.value as Provider;
|
const nextProvider = event.currentTarget.value as Provider;
|
||||||
@@ -3917,6 +3940,7 @@ export default function App() {
|
|||||||
<label className="block min-w-0">
|
<label className="block min-w-0">
|
||||||
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Model</span>
|
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Model</span>
|
||||||
<ModelCombobox
|
<ModelCombobox
|
||||||
|
className="h-11 min-w-0 bg-background/70 text-base md:h-10 md:text-sm"
|
||||||
options={chatSettingsProviderModelOptions}
|
options={chatSettingsProviderModelOptions}
|
||||||
value={chatSettingsModelDraft}
|
value={chatSettingsModelDraft}
|
||||||
disabled={isSavingChatSettings}
|
disabled={isSavingChatSettings}
|
||||||
@@ -3938,7 +3962,7 @@ export default function App() {
|
|||||||
if (chatSettingsError) setChatSettingsError(null);
|
if (chatSettingsError) setChatSettingsError(null);
|
||||||
}}
|
}}
|
||||||
placeholder="Add per-chat instructions"
|
placeholder="Add per-chat instructions"
|
||||||
className="min-h-32 resize-y border-violet-300/24 bg-background/72 text-sm text-violet-50 placeholder:text-violet-200/45"
|
className="min-h-32 resize-y border-violet-300/24 bg-background/70 text-base text-violet-50 placeholder:text-violet-200/45 md:text-sm"
|
||||||
disabled={isSavingChatSettings}
|
disabled={isSavingChatSettings}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -3952,6 +3976,7 @@ export default function App() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
className="h-11 gap-1.5 md:h-8"
|
||||||
onClick={() => setChatSettingsEnabledToolsDraft(getDefaultEnabledTools(availableChatTools))}
|
onClick={() => setChatSettingsEnabledToolsDraft(getDefaultEnabledTools(availableChatTools))}
|
||||||
disabled={isSavingChatSettings}
|
disabled={isSavingChatSettings}
|
||||||
>
|
>
|
||||||
@@ -3962,6 +3987,7 @@ export default function App() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
className="h-11 gap-1.5 md:h-8"
|
||||||
onClick={() => setChatSettingsEnabledToolsDraft([])}
|
onClick={() => setChatSettingsEnabledToolsDraft([])}
|
||||||
disabled={isSavingChatSettings}
|
disabled={isSavingChatSettings}
|
||||||
>
|
>
|
||||||
@@ -3971,31 +3997,30 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
{availableChatTools.length ? (
|
{availableChatTools.length ? (
|
||||||
availableChatTools.map((tool) => {
|
availableChatTools.map((tool) => {
|
||||||
const checked = chatSettingsEnabledToolsDraft.includes(tool.name);
|
const checked = chatSettingsEnabledToolsDraft.includes(tool.name);
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={tool.name}
|
key={tool.name}
|
||||||
className="flex cursor-pointer items-start gap-3 rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 transition hover:border-violet-300/34 hover:bg-violet-400/8"
|
title={tool.description}
|
||||||
|
className="flex min-h-11 min-w-0 cursor-pointer items-center gap-3 rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 transition hover:border-violet-300/34 hover:bg-violet-400/8 focus-within:border-violet-300/45 focus-within:ring-1 focus-within:ring-ring/70"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={() => toggleChatSettingsTool(tool.name)}
|
onChange={() => toggleChatSettingsTool(tool.name)}
|
||||||
className="mt-1 h-4 w-4 rounded border-violet-300/35 bg-background/80 accent-violet-400"
|
className="h-4 w-4 shrink-0 rounded border-violet-300/35 bg-background/80 accent-violet-400"
|
||||||
|
aria-description={tool.description}
|
||||||
disabled={isSavingChatSettings}
|
disabled={isSavingChatSettings}
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0 break-words text-sm font-medium text-violet-50">{getToolLabel(tool.name)}</span>
|
||||||
<span className="block text-sm font-medium text-violet-50">{getToolLabel(tool.name)}</span>
|
|
||||||
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">{tool.description}</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<p className="rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 text-sm text-muted-foreground">
|
<p className="rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 text-sm text-muted-foreground md:col-span-2">
|
||||||
No chat tools are available.
|
No chat tools are available.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -4003,8 +4028,8 @@ export default function App() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{chatSettingsError ? <p className="mt-3 text-sm text-rose-300">{chatSettingsError}</p> : null}
|
{chatSettingsError ? <p role="alert" className="chat-settings-error shrink-0 break-words border-t border-violet-300/16 text-sm text-rose-300">{chatSettingsError}</p> : null}
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
<div className="hidden shrink-0 justify-end gap-2 border-t border-violet-300/16 p-5 md:flex">
|
||||||
<Button type="button" variant="secondary" onClick={() => setIsChatSettingsOpen(false)} disabled={isSavingChatSettings}>
|
<Button type="button" variant="secondary" onClick={() => setIsChatSettingsOpen(false)} disabled={isSavingChatSettings}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -4014,7 +4039,7 @@ export default function App() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</dialog>
|
||||||
) : null}
|
) : null}
|
||||||
{renameChatDialog ? (
|
{renameChatDialog ? (
|
||||||
<div
|
<div
|
||||||
@@ -4073,7 +4098,7 @@ export default function App() {
|
|||||||
) : null}
|
) : null}
|
||||||
{isQuickQuestionOpen ? (
|
{isQuickQuestionOpen ? (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-3 md:p-6"
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (event.target === event.currentTarget) setIsQuickQuestionOpen(false);
|
if (event.target === event.currentTarget) setIsQuickQuestionOpen(false);
|
||||||
}}
|
}}
|
||||||
@@ -4151,7 +4176,7 @@ export default function App() {
|
|||||||
const nextProvider = event.currentTarget.value as Provider;
|
const nextProvider = event.currentTarget.value as Provider;
|
||||||
setQuickProvider(nextProvider);
|
setQuickProvider(nextProvider);
|
||||||
const options = getModelOptions(modelCatalog, nextProvider);
|
const options = getModelOptions(modelCatalog, nextProvider);
|
||||||
setQuickModel(pickProviderModel(options, quickProviderModelPreferences[nextProvider]));
|
setQuickModel(pickQuickQuestionModel(nextProvider, options, quickProviderModelPreferences[nextProvider]));
|
||||||
}}
|
}}
|
||||||
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
|
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
|
||||||
aria-label="Quick question provider"
|
aria-label="Quick question provider"
|
||||||
|
|||||||
@@ -378,13 +378,13 @@ function ToolCallStack({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{!expanded && hiddenCount ? (
|
{!expanded && hiddenCount ? (
|
||||||
<span className="absolute bottom-1 right-10 z-20 rounded-full border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
|
<span className="absolute bottom-1 right-10 z-20 rounded-none border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
|
||||||
+{hiddenCount}
|
+{hiddenCount}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full"
|
className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-none"
|
||||||
aria-expanded={expanded ? "true" : "false"}
|
aria-expanded={expanded ? "true" : "false"}
|
||||||
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
|
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
|
||||||
title={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
|
title={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
|
||||||
@@ -603,7 +603,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
|||||||
className={cn(
|
className={cn(
|
||||||
"max-w-[85%] space-y-3",
|
"max-w-[85%] space-y-3",
|
||||||
isUser
|
isUser
|
||||||
? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
|
? "chat-user-message border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
|
||||||
: "text-base leading-7 text-violet-50"
|
: "text-base leading-7 text-violet-50"
|
||||||
)}
|
)}
|
||||||
onContextMenu={
|
onContextMenu={
|
||||||
|
|||||||
+48
-26
@@ -31,7 +31,7 @@
|
|||||||
--secondary-foreground: 254 38% 94%;
|
--secondary-foreground: 254 38% 94%;
|
||||||
--accent: 188 86% 50%;
|
--accent: 188 86% 50%;
|
||||||
--accent-foreground: 230 45% 8%;
|
--accent-foreground: 230 45% 8%;
|
||||||
--radius: 0.5rem;
|
--radius: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -72,6 +72,7 @@ select,
|
|||||||
textarea {
|
textarea {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
@@ -113,13 +114,39 @@ textarea {
|
|||||||
max(0.75rem, var(--safe-area-left));
|
max(0.75rem, var(--safe-area-left));
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
.chat-settings-header {
|
||||||
.app-safe-frame {
|
|
||||||
padding:
|
padding:
|
||||||
max(0.5rem, var(--safe-area-top))
|
calc(0.5rem + var(--safe-area-top))
|
||||||
max(0.5rem, var(--safe-area-right))
|
max(0.75rem, var(--safe-area-right))
|
||||||
max(0.5rem, var(--safe-area-bottom))
|
0.5rem
|
||||||
max(0.5rem, var(--safe-area-left));
|
max(0.75rem, var(--safe-area-left));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-settings-content {
|
||||||
|
padding:
|
||||||
|
1rem
|
||||||
|
max(1rem, var(--safe-area-right))
|
||||||
|
max(1.5rem, var(--safe-area-bottom))
|
||||||
|
max(1rem, var(--safe-area-left));
|
||||||
|
scroll-padding-block: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-settings-error {
|
||||||
|
padding:
|
||||||
|
0.75rem
|
||||||
|
max(1rem, var(--safe-area-right))
|
||||||
|
max(0.75rem, var(--safe-area-bottom))
|
||||||
|
max(1rem, var(--safe-area-left));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.chat-settings-header,
|
||||||
|
.chat-settings-content {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-settings-error {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-search-safe-pad {
|
.app-search-safe-pad {
|
||||||
@@ -132,19 +159,11 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (horizontal-viewport-segments: 2) {
|
@media (horizontal-viewport-segments: 2) {
|
||||||
.app-safe-frame {
|
|
||||||
padding:
|
|
||||||
max(0.5rem, var(--safe-area-top))
|
|
||||||
max(0.5rem, var(--safe-area-right))
|
|
||||||
max(0.5rem, var(--safe-area-bottom))
|
|
||||||
max(0.5rem, var(--safe-area-left));
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-shell {
|
.workspace-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns:
|
grid-template-columns:
|
||||||
calc(env(viewport-segment-width 0 0) - max(0.5rem, var(--safe-area-left)))
|
calc(env(viewport-segment-width 0 0) - var(--safe-area-left))
|
||||||
calc(env(viewport-segment-width 1 0) - max(0.5rem, var(--safe-area-right)));
|
calc(env(viewport-segment-width 1 0) - var(--safe-area-right));
|
||||||
column-gap: calc(env(viewport-segment-left 1 0) - env(viewport-segment-right 0 0));
|
column-gap: calc(env(viewport-segment-left 1 0) - env(viewport-segment-right 0 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,14 +172,10 @@ textarea {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
transform: none;
|
transform: none;
|
||||||
border-width: 1px;
|
|
||||||
border-radius: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-content {
|
.workspace-content {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-width: 1px;
|
|
||||||
border-radius: 1rem;
|
|
||||||
touch-action: auto;
|
touch-action: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +194,13 @@ textarea {
|
|||||||
0 14px 36px hsl(240 80% 2% / 0.28);
|
0 14px 36px hsl(240 80% 2% / 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-user-message {
|
||||||
|
/* Preserve the original bubble radius independently of the square UI theme.
|
||||||
|
Browsers without corner-shape support fall back to circular corners. */
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
corner-shape: squircle;
|
||||||
|
}
|
||||||
|
|
||||||
.tool-call-stack-shell {
|
.tool-call-stack-shell {
|
||||||
perspective: 900px;
|
perspective: 900px;
|
||||||
transform-style: preserve-3d;
|
transform-style: preserve-3d;
|
||||||
@@ -430,7 +452,7 @@ textarea {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
border: 1px solid hsl(var(--border) / 0.86);
|
border: 1px solid hsl(var(--border) / 0.86);
|
||||||
border-radius: 0.625rem;
|
border-radius: var(--radius);
|
||||||
background: hsl(246 34% 10% / 0.76);
|
background: hsl(246 34% 10% / 0.76);
|
||||||
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.06);
|
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.06);
|
||||||
}
|
}
|
||||||
@@ -449,7 +471,7 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.md-table-scroll::-webkit-scrollbar-thumb {
|
.md-table-scroll::-webkit-scrollbar-thumb {
|
||||||
border-radius: 9999px;
|
border-radius: var(--radius);
|
||||||
background: hsl(263 78% 72% / 0.34);
|
background: hsl(263 78% 72% / 0.34);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,7 +583,7 @@ textarea {
|
|||||||
|
|
||||||
.md-content code {
|
.md-content code {
|
||||||
background: hsl(249 40% 10% / 0.78);
|
background: hsl(249 40% 10% / 0.78);
|
||||||
border-radius: 0.3rem;
|
border-radius: var(--radius);
|
||||||
padding: 0.05rem 0.3rem;
|
padding: 0.05rem 0.3rem;
|
||||||
font-size: 0.86em;
|
font-size: 0.86em;
|
||||||
box-decoration-break: clone;
|
box-decoration-break: clone;
|
||||||
@@ -571,7 +593,7 @@ textarea {
|
|||||||
.md-content pre {
|
.md-content pre {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border: 1px solid hsl(253 31% 29% / 0.72);
|
border: 1px solid hsl(253 31% 29% / 0.72);
|
||||||
border-radius: 0.625rem;
|
border-radius: var(--radius);
|
||||||
background: hsl(249 40% 10% / 0.82);
|
background: hsl(249 40% 10% / 0.82);
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.05);
|
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.05);
|
||||||
@@ -595,7 +617,7 @@ textarea {
|
|||||||
.md-cite-token {
|
.md-cite-token {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: 9999px;
|
border-radius: var(--radius);
|
||||||
border: 1px solid hsl(274 40% 55%);
|
border: 1px solid hsl(274 40% 55%);
|
||||||
background: hsl(277 33% 21%);
|
background: hsl(277 33% 21%);
|
||||||
color: hsl(304 42% 96%);
|
color: hsl(304 42% 96%);
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import type { Provider } from "./api";
|
import type { Provider } from "./api";
|
||||||
|
|
||||||
|
export const QUICK_QUESTION_DEFAULT_MODEL = "chat-latest";
|
||||||
|
|
||||||
|
export function pickQuickQuestionModel(provider: Provider, options: string[], preferred: string | null = null) {
|
||||||
|
if (preferred?.trim()) return preferred.trim();
|
||||||
|
if (provider === "openai") return QUICK_QUESTION_DEFAULT_MODEL;
|
||||||
|
return options[0] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
export function buildQuickQuestionRequest({
|
export function buildQuickQuestionRequest({
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
|
|||||||
@@ -29,9 +29,14 @@ const config: Config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
|
// Keep surfaces square at every size; rounded-full is reserved for dots.
|
||||||
|
DEFAULT: "var(--radius)",
|
||||||
|
sm: "var(--radius)",
|
||||||
|
md: "var(--radius)",
|
||||||
lg: "var(--radius)",
|
lg: "var(--radius)",
|
||||||
md: "calc(var(--radius) - 2px)",
|
xl: "var(--radius)",
|
||||||
sm: "calc(var(--radius) - 4px)",
|
"2xl": "var(--radius)",
|
||||||
|
"3xl": "var(--radius)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,27 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { buildQuickQuestionRequest } from "../src/lib/quick-question.ts";
|
import { buildQuickQuestionRequest, pickQuickQuestionModel } from "../src/lib/quick-question.ts";
|
||||||
|
|
||||||
|
test("quick questions default to chat-latest before and after the model catalog loads", () => {
|
||||||
|
for (const options of [[], ["gpt-4.1-mini"], ["gpt-4.1-mini", "chat-latest"]]) {
|
||||||
|
const model = pickQuickQuestionModel("openai", options);
|
||||||
|
assert.equal(model, "chat-latest");
|
||||||
|
assert.deepEqual(buildQuickQuestionRequest({ provider: "openai", model, content: "What is UTC?" }), {
|
||||||
|
provider: "openai",
|
||||||
|
model: "chat-latest",
|
||||||
|
question: "What is UTC?",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quick questions preserve saved models and use provider-specific choices when switching providers", () => {
|
||||||
|
assert.equal(pickQuickQuestionModel("openai", ["chat-latest"], " gpt-4.1-mini "), "gpt-4.1-mini");
|
||||||
|
assert.equal(pickQuickQuestionModel("openai", ["gpt-4.1-mini"], " "), "chat-latest");
|
||||||
|
assert.equal(pickQuickQuestionModel("anthropic", ["claude-3-5-sonnet-latest"]), "claude-3-5-sonnet-latest");
|
||||||
|
assert.equal(pickQuickQuestionModel("anthropic", ["claude-3-5-sonnet-latest"], "claude-3-haiku"), "claude-3-haiku");
|
||||||
|
assert.equal(pickQuickQuestionModel("anthropic", []), "");
|
||||||
|
assert.equal(pickQuickQuestionModel("openai", ["gpt-4.1-mini", "chat-latest"]), "chat-latest");
|
||||||
|
});
|
||||||
|
|
||||||
test("quick question requests use the dedicated server endpoint shape", () => {
|
test("quick question requests use the dedicated server endpoint shape", () => {
|
||||||
const request = buildQuickQuestionRequest({
|
const request = buildQuickQuestionRequest({
|
||||||
|
|||||||
Reference in New Issue
Block a user