Compare commits
2 Commits
f71b69ca8b
...
codex/syst
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3bb8503aa | ||
|
|
93e34d086f |
5
dist/default.conf
vendored
5
dist/default.conf
vendored
@@ -17,11 +17,6 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /manifest.webmanifest {
|
|
||||||
default_type application/manifest+json;
|
|
||||||
try_files $uri =404;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,23 +42,6 @@ Chat upload limits:
|
|||||||
- `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.
|
||||||
|
|
||||||
## Chat Tools
|
|
||||||
|
|
||||||
### `GET /v1/chat-tools`
|
|
||||||
- Response:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"tools": [
|
|
||||||
{ "name": "web_search", "description": "..." },
|
|
||||||
{ "name": "fetch_url", "description": "..." }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Behavior notes:
|
|
||||||
- Lists Sybil-managed chat tools that can be enabled for `openai` and `xai` chat completions.
|
|
||||||
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
|
|
||||||
|
|
||||||
## Active Runs
|
## Active Runs
|
||||||
|
|
||||||
### `GET /v1/active-runs`
|
### `GET /v1/active-runs`
|
||||||
@@ -89,14 +72,10 @@ Behavior notes:
|
|||||||
"title": "optional title",
|
"title": "optional title",
|
||||||
"createdAt": "2026-02-14T00:00:00.000Z",
|
"createdAt": "2026-02-14T00:00:00.000Z",
|
||||||
"updatedAt": "2026-02-14T00:00:00.000Z",
|
"updatedAt": "2026-02-14T00:00:00.000Z",
|
||||||
"starred": true,
|
|
||||||
"starredAt": "2026-02-14T01:00:00.000Z",
|
|
||||||
"initiatedProvider": "openai",
|
"initiatedProvider": "openai",
|
||||||
"initiatedModel": "gpt-4.1-mini",
|
"initiatedModel": "gpt-4.1-mini",
|
||||||
"lastUsedProvider": "openai",
|
"lastUsedProvider": "openai",
|
||||||
"lastUsedModel": "gpt-4.1-mini",
|
"lastUsedModel": "gpt-4.1-mini"
|
||||||
"additionalSystemPrompt": null,
|
|
||||||
"enabledTools": ["web_search", "fetch_url"]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "search",
|
"type": "search",
|
||||||
@@ -104,9 +83,7 @@ Behavior notes:
|
|||||||
"title": "optional title",
|
"title": "optional title",
|
||||||
"query": "search query",
|
"query": "search query",
|
||||||
"createdAt": "2026-02-14T00:00:00.000Z",
|
"createdAt": "2026-02-14T00:00:00.000Z",
|
||||||
"updatedAt": "2026-02-14T00:00:00.000Z",
|
"updatedAt": "2026-02-14T00:00:00.000Z"
|
||||||
"starred": false,
|
|
||||||
"starredAt": null
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -116,7 +93,6 @@ Behavior notes:
|
|||||||
- This endpoint is intended for combined conversation/search lists such as sidebars.
|
- This endpoint is intended for combined conversation/search lists such as sidebars.
|
||||||
- The legacy `GET /v1/chats` and `GET /v1/searches` endpoints remain available for clients that need separate collections.
|
- The legacy `GET /v1/chats` and `GET /v1/searches` endpoints remain available for clients that need separate collections.
|
||||||
- The response currently combines up to 100 chats and up to 100 searches.
|
- The response currently combines up to 100 chats and up to 100 searches.
|
||||||
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
|
|
||||||
|
|
||||||
## Chats
|
## Chats
|
||||||
|
|
||||||
@@ -130,8 +106,6 @@ Behavior notes:
|
|||||||
"title": "optional title",
|
"title": "optional title",
|
||||||
"provider": "optional openai|anthropic|xai|hermes-agent",
|
"provider": "optional openai|anthropic|xai|hermes-agent",
|
||||||
"model": "optional model id",
|
"model": "optional model id",
|
||||||
"additionalSystemPrompt": "optional stored system prompt",
|
|
||||||
"enabledTools": ["web_search", "fetch_url"],
|
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"role": "system|user|assistant|tool",
|
"role": "system|user|assistant|tool",
|
||||||
@@ -147,29 +121,13 @@ Behavior notes:
|
|||||||
Behavior notes:
|
Behavior notes:
|
||||||
- `provider` and `model` must be supplied together when present.
|
- `provider` and `model` must be supplied together when present.
|
||||||
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`.
|
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`.
|
||||||
- `additionalSystemPrompt` is trimmed and stored on the chat; blank values are stored as `null`.
|
|
||||||
- `enabledTools` stores the enabled Sybil-managed tool names for future chat completions. Unknown tool names are ignored; omitted values default to all currently available tools.
|
|
||||||
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
|
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
|
||||||
|
|
||||||
### `PATCH /v1/chats/:chatId`
|
### `PATCH /v1/chats/:chatId`
|
||||||
- Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": string[] }`
|
- Body: `{ "title": string }`
|
||||||
- Response: `{ "chat": ChatSummary }`
|
|
||||||
- Blank titles are rejected. The server trims surrounding whitespace before storing the title.
|
|
||||||
- `additionalSystemPrompt: null` clears the stored prompt. Blank string values are also stored as `null`.
|
|
||||||
- `enabledTools: []` disables Sybil-managed tools for this chat. Omitted settings are left unchanged.
|
|
||||||
- Updating chat fields changes the returned chat's `updatedAt`.
|
|
||||||
- Not found: `404 { "message": "chat not found" }`
|
|
||||||
|
|
||||||
### `PATCH /v1/chats/:chatId/star`
|
|
||||||
- Body: `{ "starred": boolean }`
|
|
||||||
- Response: `{ "chat": ChatSummary }`
|
- Response: `{ "chat": ChatSummary }`
|
||||||
- Not found: `404 { "message": "chat not found" }`
|
- Not found: `404 { "message": "chat not found" }`
|
||||||
|
|
||||||
Behavior notes:
|
|
||||||
- Starring adds the chat to the reserved `starred` project and sets `starredAt` to the membership creation time.
|
|
||||||
- Unstarring removes that membership and returns `starred: false`, `starredAt: null`.
|
|
||||||
- This does not modify the chat transcript or chat `updatedAt`.
|
|
||||||
|
|
||||||
### `POST /v1/chats/title/suggest`
|
### `POST /v1/chats/title/suggest`
|
||||||
- Body:
|
- Body:
|
||||||
```json
|
```json
|
||||||
@@ -182,8 +140,7 @@ Behavior notes:
|
|||||||
|
|
||||||
Behavior notes:
|
Behavior notes:
|
||||||
- If the chat already has a non-empty title, server returns the existing chat unchanged.
|
- If the chat already has a non-empty title, server returns the existing chat unchanged.
|
||||||
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
|
- Server always uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
|
||||||
- When no title exists at write time, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
|
|
||||||
|
|
||||||
### `DELETE /v1/chats/:chatId`
|
### `DELETE /v1/chats/:chatId`
|
||||||
- Response: `{ "deleted": true }`
|
- Response: `{ "deleted": true }`
|
||||||
@@ -262,8 +219,6 @@ Notes:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"additionalSystemPrompt": "optional one-off system prompt",
|
|
||||||
"enabledTools": ["web_search", "fetch_url"],
|
|
||||||
"temperature": 0.2,
|
"temperature": 0.2,
|
||||||
"maxTokens": 256
|
"maxTokens": 256
|
||||||
}
|
}
|
||||||
@@ -283,8 +238,6 @@ Notes:
|
|||||||
Behavior notes:
|
Behavior notes:
|
||||||
- If `chatId` is present, server validates chat existence.
|
- If `chatId` is present, server validates chat existence.
|
||||||
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
|
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
|
||||||
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
|
||||||
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
|
||||||
- Server persists final assistant output and call metadata (`LlmCall`) in DB.
|
- Server persists final assistant output and call metadata (`LlmCall`) in DB.
|
||||||
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
|
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
|
||||||
- Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`.
|
- Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`.
|
||||||
@@ -323,24 +276,8 @@ Behavior notes:
|
|||||||
- Response: `{ "searches": SearchSummary[] }`
|
- Response: `{ "searches": SearchSummary[] }`
|
||||||
|
|
||||||
### `POST /v1/searches`
|
### `POST /v1/searches`
|
||||||
- Body: `{ "title"?: string, "query"?: string, "reuseByQuery"?: boolean }`
|
- Body: `{ "title"?: string, "query"?: string }`
|
||||||
- Response: `{ "search": SearchSummary, "reused": boolean, "cacheHit": boolean }`
|
|
||||||
|
|
||||||
Behavior notes:
|
|
||||||
- `reuseByQuery` defaults to `false`, preserving the normal create-a-new-search behavior.
|
|
||||||
- When `reuseByQuery` is `true` and `query` is present, the backend normalizes the query with `trim().toLowerCase()` and returns the most recently updated existing search with that normalized query instead of creating a duplicate.
|
|
||||||
- `cacheHit` is `true` only when the reused search has persisted results or answer text, is not currently streaming, and was updated within the 24-hour search cache window. Clients can then fetch `GET /v1/searches/:searchId` and display it without running another search.
|
|
||||||
- If a matching search exists but `cacheHit` is `false`, clients may run the search again on the returned `search.id`; the run endpoints replace that search's persisted results and answer with the latest run.
|
|
||||||
|
|
||||||
### `PATCH /v1/searches/:searchId/star`
|
|
||||||
- Body: `{ "starred": boolean }`
|
|
||||||
- Response: `{ "search": SearchSummary }`
|
- Response: `{ "search": SearchSummary }`
|
||||||
- Not found: `404 { "message": "search not found" }`
|
|
||||||
|
|
||||||
Behavior notes:
|
|
||||||
- Starring adds the search to the reserved `starred` project and sets `starredAt` to the membership creation time.
|
|
||||||
- Unstarring removes that membership and returns `starred: false`, `starredAt: null`.
|
|
||||||
- This does not modify the search results or search `updatedAt`.
|
|
||||||
|
|
||||||
### `DELETE /v1/searches/:searchId`
|
### `DELETE /v1/searches/:searchId`
|
||||||
- Response: `{ "deleted": true }`
|
- Response: `{ "deleted": true }`
|
||||||
@@ -414,14 +351,10 @@ Behavior notes:
|
|||||||
"title": null,
|
"title": null,
|
||||||
"createdAt": "...",
|
"createdAt": "...",
|
||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
"starred": false,
|
|
||||||
"starredAt": null,
|
|
||||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
"initiatedModel": "string|null",
|
"initiatedModel": "string|null",
|
||||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
"lastUsedModel": "string|null",
|
"lastUsedModel": "string|null"
|
||||||
"additionalSystemPrompt": null,
|
|
||||||
"enabledTools": ["web_search", "fetch_url"]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -466,21 +399,17 @@ Behavior notes:
|
|||||||
"title": null,
|
"title": null,
|
||||||
"createdAt": "...",
|
"createdAt": "...",
|
||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
"starred": false,
|
|
||||||
"starredAt": null,
|
|
||||||
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
"initiatedModel": "string|null",
|
"initiatedModel": "string|null",
|
||||||
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
|
||||||
"lastUsedModel": "string|null",
|
"lastUsedModel": "string|null",
|
||||||
"additionalSystemPrompt": null,
|
|
||||||
"enabledTools": ["web_search", "fetch_url"],
|
|
||||||
"messages": [Message]
|
"messages": [Message]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`SearchSummary`
|
`SearchSummary`
|
||||||
```json
|
```json
|
||||||
{ "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "...", "starred": false, "starredAt": null }
|
{ "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "..." }
|
||||||
```
|
```
|
||||||
|
|
||||||
`SearchDetail`
|
`SearchDetail`
|
||||||
@@ -491,8 +420,6 @@ Behavior notes:
|
|||||||
"query": "...",
|
"query": "...",
|
||||||
"createdAt": "...",
|
"createdAt": "...",
|
||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
"starred": false,
|
|
||||||
"starredAt": null,
|
|
||||||
"requestId": "...",
|
"requestId": "...",
|
||||||
"latencyMs": 123,
|
"latencyMs": 123,
|
||||||
"error": null,
|
"error": null,
|
||||||
|
|||||||
@@ -49,8 +49,6 @@ Authentication:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"additionalSystemPrompt": "optional one-off system prompt",
|
|
||||||
"enabledTools": ["web_search", "fetch_url"],
|
|
||||||
"temperature": 0.2,
|
"temperature": 0.2,
|
||||||
"maxTokens": 256
|
"maxTokens": 256
|
||||||
}
|
}
|
||||||
@@ -62,8 +60,6 @@ Notes:
|
|||||||
- If `chatId` is provided, backend validates it exists.
|
- If `chatId` is provided, backend validates it exists.
|
||||||
- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata.
|
- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata.
|
||||||
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
|
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
|
||||||
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
|
|
||||||
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
|
|
||||||
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
|
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
|
||||||
|
|
||||||
Persisted chat streams with a `chatId` are backend-owned active runs:
|
Persisted chat streams with a `chatId` are backend-owned active runs:
|
||||||
|
|||||||
@@ -74,26 +74,6 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
return response.chat
|
return response.chat
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
|
||||||
let response = try await request(
|
|
||||||
"/v1/chats/\(chatID)",
|
|
||||||
method: "PATCH",
|
|
||||||
body: AnyEncodable(ChatTitleUpdateBody(title: title)),
|
|
||||||
responseType: ChatCreateResponse.self
|
|
||||||
)
|
|
||||||
return response.chat
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
|
|
||||||
let response = try await request(
|
|
||||||
"/v1/chats/\(chatID)/star",
|
|
||||||
method: "PATCH",
|
|
||||||
body: AnyEncodable(StarUpdateBody(starred: starred)),
|
|
||||||
responseType: ChatCreateResponse.self
|
|
||||||
)
|
|
||||||
return response.chat
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteChat(chatID: String) async throws {
|
func deleteChat(chatID: String) async throws {
|
||||||
_ = try await request("/v1/chats/\(chatID)", method: "DELETE", responseType: DeleteResponse.self)
|
_ = try await request("/v1/chats/\(chatID)", method: "DELETE", responseType: DeleteResponse.self)
|
||||||
}
|
}
|
||||||
@@ -138,16 +118,6 @@ actor SybilAPIClient: SybilAPIClienting {
|
|||||||
return response.chat
|
return response.chat
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary {
|
|
||||||
let response = try await request(
|
|
||||||
"/v1/searches/\(searchID)/star",
|
|
||||||
method: "PATCH",
|
|
||||||
body: AnyEncodable(StarUpdateBody(starred: starred)),
|
|
||||||
responseType: SearchCreateResponse.self
|
|
||||||
)
|
|
||||||
return response.search
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteSearch(searchID: String) async throws {
|
func deleteSearch(searchID: String) async throws {
|
||||||
_ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self)
|
_ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self)
|
||||||
}
|
}
|
||||||
@@ -671,14 +641,6 @@ private struct ChatCreateBody: Encodable {
|
|||||||
var messages: [CompletionRequestMessage]?
|
var messages: [CompletionRequestMessage]?
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ChatTitleUpdateBody: Encodable {
|
|
||||||
var title: String
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct StarUpdateBody: Encodable {
|
|
||||||
var starred: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SearchCreateBody: Encodable {
|
private struct SearchCreateBody: Encodable {
|
||||||
var title: String?
|
var title: String?
|
||||||
var query: String?
|
var query: String?
|
||||||
|
|||||||
@@ -11,15 +11,12 @@ protocol SybilAPIClienting: Sendable {
|
|||||||
messages: [CompletionRequestMessage]?
|
messages: [CompletionRequestMessage]?
|
||||||
) async throws -> ChatSummary
|
) async throws -> ChatSummary
|
||||||
func getChat(chatID: String) async throws -> ChatDetail
|
func getChat(chatID: String) async throws -> ChatDetail
|
||||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
|
|
||||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
|
|
||||||
func deleteChat(chatID: String) async throws
|
func deleteChat(chatID: String) async throws
|
||||||
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
|
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
|
||||||
func listSearches() async throws -> [SearchSummary]
|
func listSearches() async throws -> [SearchSummary]
|
||||||
func createSearch(title: String?, query: String?) async throws -> SearchSummary
|
func createSearch(title: String?, query: String?) async throws -> SearchSummary
|
||||||
func getSearch(searchID: String) async throws -> SearchDetail
|
func getSearch(searchID: String) async throws -> SearchDetail
|
||||||
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary
|
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary
|
||||||
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary
|
|
||||||
func deleteSearch(searchID: String) async throws
|
func deleteSearch(searchID: String) async throws
|
||||||
func listModels() async throws -> ModelCatalogResponse
|
func listModels() async throws -> ModelCatalogResponse
|
||||||
func getActiveRuns() async throws -> ActiveRunsResponse
|
func getActiveRuns() async throws -> ActiveRunsResponse
|
||||||
|
|||||||
@@ -154,8 +154,6 @@ public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var title: String?
|
public var title: String?
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
public var starred = false
|
|
||||||
public var starredAt: Date?
|
|
||||||
public var initiatedProvider: Provider?
|
public var initiatedProvider: Provider?
|
||||||
public var initiatedModel: String?
|
public var initiatedModel: String?
|
||||||
public var lastUsedProvider: Provider?
|
public var lastUsedProvider: Provider?
|
||||||
@@ -168,8 +166,6 @@ public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var query: String?
|
public var query: String?
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
public var starred = false
|
|
||||||
public var starredAt: Date?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum WorkspaceItemType: String, Codable, Hashable, Sendable {
|
public enum WorkspaceItemType: String, Codable, Hashable, Sendable {
|
||||||
@@ -184,8 +180,6 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var query: String?
|
public var query: String?
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
public var starred = false
|
|
||||||
public var starredAt: Date?
|
|
||||||
public var initiatedProvider: Provider?
|
public var initiatedProvider: Provider?
|
||||||
public var initiatedModel: String?
|
public var initiatedModel: String?
|
||||||
public var lastUsedProvider: Provider?
|
public var lastUsedProvider: Provider?
|
||||||
@@ -198,8 +192,6 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
self.query = nil
|
self.query = nil
|
||||||
self.createdAt = chat.createdAt
|
self.createdAt = chat.createdAt
|
||||||
self.updatedAt = chat.updatedAt
|
self.updatedAt = chat.updatedAt
|
||||||
self.starred = chat.starred
|
|
||||||
self.starredAt = chat.starredAt
|
|
||||||
self.initiatedProvider = chat.initiatedProvider
|
self.initiatedProvider = chat.initiatedProvider
|
||||||
self.initiatedModel = chat.initiatedModel
|
self.initiatedModel = chat.initiatedModel
|
||||||
self.lastUsedProvider = chat.lastUsedProvider
|
self.lastUsedProvider = chat.lastUsedProvider
|
||||||
@@ -213,8 +205,6 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
self.query = search.query
|
self.query = search.query
|
||||||
self.createdAt = search.createdAt
|
self.createdAt = search.createdAt
|
||||||
self.updatedAt = search.updatedAt
|
self.updatedAt = search.updatedAt
|
||||||
self.starred = search.starred
|
|
||||||
self.starredAt = search.starredAt
|
|
||||||
self.initiatedProvider = nil
|
self.initiatedProvider = nil
|
||||||
self.initiatedModel = nil
|
self.initiatedModel = nil
|
||||||
self.lastUsedProvider = nil
|
self.lastUsedProvider = nil
|
||||||
@@ -228,8 +218,6 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
title: title,
|
title: title,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
starred: starred,
|
|
||||||
starredAt: starredAt,
|
|
||||||
initiatedProvider: initiatedProvider,
|
initiatedProvider: initiatedProvider,
|
||||||
initiatedModel: initiatedModel,
|
initiatedModel: initiatedModel,
|
||||||
lastUsedProvider: lastUsedProvider,
|
lastUsedProvider: lastUsedProvider,
|
||||||
@@ -244,9 +232,7 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
|
|||||||
title: title,
|
title: title,
|
||||||
query: query,
|
query: query,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt
|
||||||
starred: starred,
|
|
||||||
starredAt: starredAt
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,8 +377,6 @@ public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var title: String?
|
public var title: String?
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
public var starred = false
|
|
||||||
public var starredAt: Date?
|
|
||||||
public var initiatedProvider: Provider?
|
public var initiatedProvider: Provider?
|
||||||
public var initiatedModel: String?
|
public var initiatedModel: String?
|
||||||
public var lastUsedProvider: Provider?
|
public var lastUsedProvider: Provider?
|
||||||
@@ -431,8 +415,6 @@ public struct SearchDetail: Codable, Identifiable, Hashable, Sendable {
|
|||||||
public var query: String?
|
public var query: String?
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
public var updatedAt: Date
|
public var updatedAt: Date
|
||||||
public var starred = false
|
|
||||||
public var starredAt: Date?
|
|
||||||
public var requestId: String?
|
public var requestId: String?
|
||||||
public var latencyMs: Int?
|
public var latencyMs: Int?
|
||||||
public var error: String?
|
public var error: String?
|
||||||
|
|||||||
@@ -111,22 +111,8 @@ struct SybilSidebarItemList: View {
|
|||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
var isSelected: (SidebarItem) -> Bool
|
var isSelected: (SidebarItem) -> Bool
|
||||||
var onSelect: (SidebarItem) -> Void
|
var onSelect: (SidebarItem) -> Void
|
||||||
@State private var renameTarget: SidebarItem?
|
|
||||||
@State private var renameTitle = ""
|
|
||||||
|
|
||||||
private var isRenameAlertPresented: Binding<Bool> {
|
|
||||||
Binding {
|
|
||||||
renameTarget != nil
|
|
||||||
} set: { isPresented in
|
|
||||||
if !isPresented {
|
|
||||||
renameTarget = nil
|
|
||||||
renameTitle = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
|
||||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -160,23 +146,6 @@ struct SybilSidebarItemList: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button {
|
|
||||||
Task {
|
|
||||||
await viewModel.setItemStarred(item.selection, starred: !item.starred)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
|
|
||||||
}
|
|
||||||
|
|
||||||
if item.kind == .chat {
|
|
||||||
Button {
|
|
||||||
renameTarget = item
|
|
||||||
renameTitle = item.title
|
|
||||||
} label: {
|
|
||||||
Label("Rename", systemImage: "pencil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.deleteItem(item.selection)
|
await viewModel.deleteItem(item.selection)
|
||||||
@@ -194,27 +163,6 @@ struct SybilSidebarItemList: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert("Rename Chat", isPresented: isRenameAlertPresented) {
|
|
||||||
TextField("Title", text: $renameTitle)
|
|
||||||
Button("Cancel", role: .cancel) {
|
|
||||||
renameTarget = nil
|
|
||||||
renameTitle = ""
|
|
||||||
}
|
|
||||||
Button("Save") {
|
|
||||||
let target = renameTarget
|
|
||||||
let title = renameTitle
|
|
||||||
renameTarget = nil
|
|
||||||
renameTitle = ""
|
|
||||||
|
|
||||||
if let target, case let .chat(chatID) = target.selection {
|
|
||||||
Task {
|
|
||||||
await viewModel.renameChat(chatID: chatID, title: title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SybilSidebarRow: View {
|
struct SybilSidebarRow: View {
|
||||||
@@ -253,12 +201,6 @@ struct SybilSidebarRow: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.layoutPriority(1)
|
.layoutPriority(1)
|
||||||
|
|
||||||
if item.starred {
|
|
||||||
Image(systemName: "star.fill")
|
|
||||||
.font(.system(size: 10, weight: .semibold))
|
|
||||||
.foregroundStyle(.yellow)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
if item.isRunning {
|
if item.isRunning {
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ struct SidebarItem: Identifiable, Hashable {
|
|||||||
var kind: Kind
|
var kind: Kind
|
||||||
var title: String
|
var title: String
|
||||||
var updatedAt: Date
|
var updatedAt: Date
|
||||||
var starred: Bool
|
|
||||||
var starredAt: Date?
|
|
||||||
var initiatedLabel: String?
|
var initiatedLabel: String?
|
||||||
var isRunning: Bool
|
var isRunning: Bool
|
||||||
}
|
}
|
||||||
@@ -410,8 +408,6 @@ final class SybilViewModel {
|
|||||||
kind: .chat,
|
kind: .chat,
|
||||||
title: chatTitle(title: item.title, messages: nil),
|
title: chatTitle(title: item.title, messages: nil),
|
||||||
updatedAt: item.updatedAt,
|
updatedAt: item.updatedAt,
|
||||||
starred: item.starred,
|
|
||||||
starredAt: item.starredAt,
|
|
||||||
initiatedLabel: initiatedLabel,
|
initiatedLabel: initiatedLabel,
|
||||||
isRunning: isChatRowRunning(item.id)
|
isRunning: isChatRowRunning(item.id)
|
||||||
)
|
)
|
||||||
@@ -422,8 +418,6 @@ final class SybilViewModel {
|
|||||||
kind: .search,
|
kind: .search,
|
||||||
title: searchTitle(title: item.title, query: item.query),
|
title: searchTitle(title: item.title, query: item.query),
|
||||||
updatedAt: item.updatedAt,
|
updatedAt: item.updatedAt,
|
||||||
starred: item.starred,
|
|
||||||
starredAt: item.starredAt,
|
|
||||||
initiatedLabel: "exa",
|
initiatedLabel: "exa",
|
||||||
isRunning: isSearchRowRunning(item.id)
|
isRunning: isSearchRowRunning(item.id)
|
||||||
)
|
)
|
||||||
@@ -687,8 +681,6 @@ final class SybilViewModel {
|
|||||||
title: chat.title,
|
title: chat.title,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
starred: chat.starred,
|
|
||||||
starredAt: chat.starredAt,
|
|
||||||
initiatedProvider: chat.initiatedProvider,
|
initiatedProvider: chat.initiatedProvider,
|
||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
@@ -859,57 +851,6 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func renameChat(chatID: String, title: String) async {
|
|
||||||
guard isAuthenticated else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmedTitle.isEmpty else {
|
|
||||||
errorMessage = "Enter a chat title."
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
SybilLog.info(SybilLog.ui, "Renaming chat \(chatID)")
|
|
||||||
errorMessage = nil
|
|
||||||
|
|
||||||
do {
|
|
||||||
let updated = try await client().updateChatTitle(chatID: chatID, title: trimmedTitle)
|
|
||||||
applyChatSummary(updated, moveToFront: true)
|
|
||||||
} catch {
|
|
||||||
errorMessage = normalizeAPIError(error)
|
|
||||||
SybilLog.error(SybilLog.ui, "Rename failed", error: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setItemStarred(_ selection: SidebarSelection, starred: Bool) async {
|
|
||||||
guard isAuthenticated else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard case .settings = selection else {
|
|
||||||
errorMessage = nil
|
|
||||||
|
|
||||||
do {
|
|
||||||
let client = try client()
|
|
||||||
switch selection {
|
|
||||||
case let .chat(chatID):
|
|
||||||
let updated = try await client.updateChatStar(chatID: chatID, starred: starred)
|
|
||||||
applyChatSummary(updated, moveToFront: false)
|
|
||||||
case let .search(searchID):
|
|
||||||
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
|
|
||||||
applySearchSummary(updated, moveToFront: false)
|
|
||||||
case .settings:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
errorMessage = normalizeAPIError(error)
|
|
||||||
SybilLog.error(SybilLog.ui, "Star update failed", error: error)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshAfterSettingsChange() async {
|
func refreshAfterSettingsChange() async {
|
||||||
SybilLog.info(SybilLog.ui, "Settings changed, reconnecting")
|
SybilLog.info(SybilLog.ui, "Settings changed, reconnecting")
|
||||||
settings.persist()
|
settings.persist()
|
||||||
@@ -1440,47 +1381,6 @@ final class SybilViewModel {
|
|||||||
searches = items.compactMap(\.searchSummary)
|
searches = items.compactMap(\.searchSummary)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyChatSummary(_ chat: ChatSummary, moveToFront: Bool) {
|
|
||||||
if let existingIndex = chats.firstIndex(where: { $0.id == chat.id }) {
|
|
||||||
chats.remove(at: existingIndex)
|
|
||||||
chats.insert(chat, at: moveToFront ? 0 : existingIndex)
|
|
||||||
} else {
|
|
||||||
chats.insert(chat, at: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
upsertWorkspaceChat(chat, moveToFront: moveToFront)
|
|
||||||
|
|
||||||
if selectedChat?.id == chat.id {
|
|
||||||
selectedChat?.title = chat.title
|
|
||||||
selectedChat?.updatedAt = chat.updatedAt
|
|
||||||
selectedChat?.starred = chat.starred
|
|
||||||
selectedChat?.starredAt = chat.starredAt
|
|
||||||
selectedChat?.initiatedProvider = chat.initiatedProvider
|
|
||||||
selectedChat?.initiatedModel = chat.initiatedModel
|
|
||||||
selectedChat?.lastUsedProvider = chat.lastUsedProvider
|
|
||||||
selectedChat?.lastUsedModel = chat.lastUsedModel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applySearchSummary(_ search: SearchSummary, moveToFront: Bool) {
|
|
||||||
if let existingIndex = searches.firstIndex(where: { $0.id == search.id }) {
|
|
||||||
searches.remove(at: existingIndex)
|
|
||||||
searches.insert(search, at: moveToFront ? 0 : existingIndex)
|
|
||||||
} else {
|
|
||||||
searches.insert(search, at: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
upsertWorkspaceSearch(search, moveToFront: moveToFront)
|
|
||||||
|
|
||||||
if selectedSearch?.id == search.id {
|
|
||||||
selectedSearch?.title = search.title
|
|
||||||
selectedSearch?.query = search.query
|
|
||||||
selectedSearch?.updatedAt = search.updatedAt
|
|
||||||
selectedSearch?.starred = search.starred
|
|
||||||
selectedSearch?.starredAt = search.starredAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func upsertWorkspaceChat(_ chat: ChatSummary, moveToFront: Bool = true) {
|
private func upsertWorkspaceChat(_ chat: ChatSummary, moveToFront: Bool = true) {
|
||||||
upsertWorkspaceItem(WorkspaceItem(chat: chat), moveToFront: moveToFront)
|
upsertWorkspaceItem(WorkspaceItem(chat: chat), moveToFront: moveToFront)
|
||||||
}
|
}
|
||||||
@@ -1845,8 +1745,6 @@ final class SybilViewModel {
|
|||||||
title: created.title,
|
title: created.title,
|
||||||
createdAt: created.createdAt,
|
createdAt: created.createdAt,
|
||||||
updatedAt: created.updatedAt,
|
updatedAt: created.updatedAt,
|
||||||
starred: created.starred,
|
|
||||||
starredAt: created.starredAt,
|
|
||||||
initiatedProvider: created.initiatedProvider,
|
initiatedProvider: created.initiatedProvider,
|
||||||
initiatedModel: created.initiatedModel,
|
initiatedModel: created.initiatedModel,
|
||||||
lastUsedProvider: created.lastUsedProvider,
|
lastUsedProvider: created.lastUsedProvider,
|
||||||
@@ -1907,7 +1805,18 @@ final class SybilViewModel {
|
|||||||
let titleSeed = !content.isEmpty ? content : SybilChatAttachmentSupport.attachmentSummary(attachments)
|
let titleSeed = !content.isEmpty ? content : SybilChatAttachmentSupport.attachmentSummary(attachments)
|
||||||
let updated = try await client.suggestChatTitle(chatID: chatID, content: titleSeed.isEmpty ? "Uploaded files" : titleSeed)
|
let updated = try await client.suggestChatTitle(chatID: chatID, content: titleSeed.isEmpty ? "Uploaded files" : titleSeed)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.applyChatSummary(updated, moveToFront: false)
|
self.chats = self.chats.map { existing in
|
||||||
|
if existing.id == updated.id {
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
self.upsertWorkspaceChat(updated, moveToFront: false)
|
||||||
|
|
||||||
|
if self.selectedChat?.id == updated.id {
|
||||||
|
self.selectedChat?.title = updated.title
|
||||||
|
self.selectedChat?.updatedAt = updated.updatedAt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SybilLog.warning(SybilLog.app, "Chat title suggestion failed: \(SybilLog.describe(error))")
|
SybilLog.warning(SybilLog.app, "Chat title suggestion failed: \(SybilLog.describe(error))")
|
||||||
@@ -2066,8 +1975,6 @@ final class SybilViewModel {
|
|||||||
query: query,
|
query: query,
|
||||||
createdAt: currentSelectedSearch?.createdAt ?? now,
|
createdAt: currentSelectedSearch?.createdAt ?? now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
starred: currentSelectedSearch?.starred ?? false,
|
|
||||||
starredAt: currentSelectedSearch?.starredAt,
|
|
||||||
requestId: nil,
|
requestId: nil,
|
||||||
latencyMs: nil,
|
latencyMs: nil,
|
||||||
error: nil,
|
error: nil,
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ private struct MockClientCallSnapshot: Sendable {
|
|||||||
var listSearches = 0
|
var listSearches = 0
|
||||||
var createChat = 0
|
var createChat = 0
|
||||||
var getChat = 0
|
var getChat = 0
|
||||||
var updateChatTitle = 0
|
|
||||||
var updateChatStar = 0
|
|
||||||
var updateSearchStar = 0
|
|
||||||
var getSearch = 0
|
var getSearch = 0
|
||||||
var getActiveRuns = 0
|
var getActiveRuns = 0
|
||||||
var runCompletionStream = 0
|
var runCompletionStream = 0
|
||||||
@@ -35,9 +32,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
private let chatDetails: [String: ChatDetail]
|
private let chatDetails: [String: ChatDetail]
|
||||||
private let searchDetails: [String: SearchDetail]
|
private let searchDetails: [String: SearchDetail]
|
||||||
private let createChatResponse: ChatSummary?
|
private let createChatResponse: ChatSummary?
|
||||||
private let updateChatTitleResponses: [String: ChatSummary]
|
|
||||||
private let updateChatStarResponses: [String: ChatSummary]
|
|
||||||
private let updateSearchStarResponses: [String: SearchSummary]
|
|
||||||
private let activeRunsResponse: ActiveRunsResponse
|
private let activeRunsResponse: ActiveRunsResponse
|
||||||
|
|
||||||
private var snapshot = MockClientCallSnapshot()
|
private var snapshot = MockClientCallSnapshot()
|
||||||
@@ -63,9 +57,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
chatDetails: [String: ChatDetail] = [:],
|
chatDetails: [String: ChatDetail] = [:],
|
||||||
searchDetails: [String: SearchDetail] = [:],
|
searchDetails: [String: SearchDetail] = [:],
|
||||||
createChatResponse: ChatSummary? = nil,
|
createChatResponse: ChatSummary? = nil,
|
||||||
updateChatTitleResponses: [String: ChatSummary] = [:],
|
|
||||||
updateChatStarResponses: [String: ChatSummary] = [:],
|
|
||||||
updateSearchStarResponses: [String: SearchSummary] = [:],
|
|
||||||
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
|
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
|
||||||
workspaceItemsResponse: [WorkspaceItem]? = nil
|
workspaceItemsResponse: [WorkspaceItem]? = nil
|
||||||
) {
|
) {
|
||||||
@@ -75,9 +66,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
self.chatDetails = chatDetails
|
self.chatDetails = chatDetails
|
||||||
self.searchDetails = searchDetails
|
self.searchDetails = searchDetails
|
||||||
self.createChatResponse = createChatResponse
|
self.createChatResponse = createChatResponse
|
||||||
self.updateChatTitleResponses = updateChatTitleResponses
|
|
||||||
self.updateChatStarResponses = updateChatStarResponses
|
|
||||||
self.updateSearchStarResponses = updateSearchStarResponses
|
|
||||||
self.activeRunsResponse = activeRunsResponse
|
self.activeRunsResponse = activeRunsResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,22 +182,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
return detail
|
return detail
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
|
|
||||||
snapshot.updateChatTitle += 1
|
|
||||||
guard let summary = updateChatTitleResponses[chatID] else {
|
|
||||||
throw UnexpectedClientCall()
|
|
||||||
}
|
|
||||||
return summary
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
|
|
||||||
snapshot.updateChatStar += 1
|
|
||||||
guard let summary = updateChatStarResponses[chatID] else {
|
|
||||||
throw UnexpectedClientCall()
|
|
||||||
}
|
|
||||||
return summary
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteChat(chatID: String) async throws {
|
func deleteChat(chatID: String) async throws {
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
@@ -245,14 +217,6 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary {
|
|
||||||
snapshot.updateSearchStar += 1
|
|
||||||
guard let summary = updateSearchStarResponses[searchID] else {
|
|
||||||
throw UnexpectedClientCall()
|
|
||||||
}
|
|
||||||
return summary
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteSearch(searchID: String) async throws {
|
func deleteSearch(searchID: String) async throws {
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
@@ -497,77 +461,6 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func renameChatUpdatesSidebarAndSelectedTranscriptTitle() async throws {
|
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_150)
|
|
||||||
let original = makeChatSummary(id: "chat-rename", date: date)
|
|
||||||
let renamed = ChatSummary(
|
|
||||||
id: "chat-rename",
|
|
||||||
title: "Renamed chat",
|
|
||||||
createdAt: date,
|
|
||||||
updatedAt: date.addingTimeInterval(60),
|
|
||||||
initiatedProvider: .openai,
|
|
||||||
initiatedModel: "gpt-4.1-mini",
|
|
||||||
lastUsedProvider: .openai,
|
|
||||||
lastUsedModel: "gpt-4.1-mini"
|
|
||||||
)
|
|
||||||
let detail = makeChatDetail(id: "chat-rename", date: date, body: "existing transcript")
|
|
||||||
let client = MockSybilClient(
|
|
||||||
chatsResponse: [original],
|
|
||||||
updateChatTitleResponses: ["chat-rename": renamed]
|
|
||||||
)
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
viewModel.isAuthenticated = true
|
|
||||||
viewModel.isCheckingSession = false
|
|
||||||
viewModel.chats = [original]
|
|
||||||
viewModel.workspaceItems = [WorkspaceItem(chat: original)]
|
|
||||||
viewModel.selectedItem = .chat("chat-rename")
|
|
||||||
viewModel.selectedChat = detail
|
|
||||||
|
|
||||||
await viewModel.renameChat(chatID: "chat-rename", title: " Renamed chat ")
|
|
||||||
|
|
||||||
let snapshot = await client.currentSnapshot()
|
|
||||||
#expect(snapshot.updateChatTitle == 1)
|
|
||||||
#expect(viewModel.sidebarItems.first?.title == "Renamed chat")
|
|
||||||
#expect(viewModel.selectedChat?.title == "Renamed chat")
|
|
||||||
#expect(viewModel.errorMessage == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func starringItemsUpdatesSidebarState() async throws {
|
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_175)
|
|
||||||
let chat = makeChatSummary(id: "chat-star", date: date)
|
|
||||||
let search = makeSearchSummary(id: "search-star", date: date)
|
|
||||||
var starredChat = chat
|
|
||||||
starredChat.starred = true
|
|
||||||
starredChat.starredAt = date.addingTimeInterval(5)
|
|
||||||
var starredSearch = search
|
|
||||||
starredSearch.starred = true
|
|
||||||
starredSearch.starredAt = date.addingTimeInterval(10)
|
|
||||||
|
|
||||||
let client = MockSybilClient(
|
|
||||||
chatsResponse: [chat],
|
|
||||||
searchesResponse: [search],
|
|
||||||
updateChatStarResponses: ["chat-star": starredChat],
|
|
||||||
updateSearchStarResponses: ["search-star": starredSearch]
|
|
||||||
)
|
|
||||||
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
|
||||||
viewModel.isAuthenticated = true
|
|
||||||
viewModel.isCheckingSession = false
|
|
||||||
viewModel.chats = [chat]
|
|
||||||
viewModel.searches = [search]
|
|
||||||
viewModel.workspaceItems = [WorkspaceItem(chat: chat), WorkspaceItem(search: search)]
|
|
||||||
|
|
||||||
await viewModel.setItemStarred(.chat("chat-star"), starred: true)
|
|
||||||
await viewModel.setItemStarred(.search("search-star"), starred: true)
|
|
||||||
|
|
||||||
let snapshot = await client.currentSnapshot()
|
|
||||||
#expect(snapshot.updateChatStar == 1)
|
|
||||||
#expect(snapshot.updateSearchStar == 1)
|
|
||||||
#expect(viewModel.sidebarItems.first(where: { $0.selection == .chat("chat-star") })?.starred == true)
|
|
||||||
#expect(viewModel.sidebarItems.first(where: { $0.selection == .search("search-star") })?.starred == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
|
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
|
||||||
let date = Date(timeIntervalSince1970: 1_700_000_200)
|
let date = Date(timeIntervalSince1970: 1_700_000_200)
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "Project" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" DATETIME NOT NULL,
|
|
||||||
"kind" TEXT NOT NULL DEFAULT 'folder',
|
|
||||||
"title" TEXT NOT NULL,
|
|
||||||
"userId" TEXT,
|
|
||||||
CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "ProjectItem" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"projectId" TEXT NOT NULL,
|
|
||||||
"chatId" TEXT,
|
|
||||||
"searchId" TEXT,
|
|
||||||
CONSTRAINT "ProjectItem_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
CONSTRAINT "ProjectItem_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
CONSTRAINT "ProjectItem_searchId_fkey" FOREIGN KEY ("searchId") REFERENCES "Search" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
CONSTRAINT "ProjectItem_one_target_check" CHECK (("chatId" IS NOT NULL AND "searchId" IS NULL) OR ("chatId" IS NULL AND "searchId" IS NOT NULL))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Project_kind_idx" ON "Project"("kind");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Project_userId_idx" ON "Project"("userId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "ProjectItem_projectId_chatId_key" ON "ProjectItem"("projectId", "chatId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "ProjectItem_projectId_searchId_key" ON "ProjectItem"("projectId", "searchId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "ProjectItem_projectId_createdAt_idx" ON "ProjectItem"("projectId", "createdAt");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "ProjectItem_chatId_idx" ON "ProjectItem"("chatId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "ProjectItem_searchId_idx" ON "ProjectItem"("searchId");
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
-- Add normalized search query lookup key for cache/reuse behavior.
|
|
||||||
ALTER TABLE "Search" ADD COLUMN "queryNormalized" TEXT;
|
|
||||||
|
|
||||||
UPDATE "Search"
|
|
||||||
SET "queryNormalized" = lower(trim("query"))
|
|
||||||
WHERE "query" IS NOT NULL AND trim("query") != '';
|
|
||||||
|
|
||||||
CREATE INDEX "Search_queryNormalized_updatedAt_idx" ON "Search"("queryNormalized", "updatedAt");
|
|
||||||
@@ -27,11 +27,6 @@ enum SearchSource {
|
|||||||
exa
|
exa
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ProjectKind {
|
|
||||||
starred
|
|
||||||
folder
|
|
||||||
}
|
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -42,7 +37,6 @@ model User {
|
|||||||
|
|
||||||
chats Chat[]
|
chats Chat[]
|
||||||
searches Search[]
|
searches Search[]
|
||||||
projects Project[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Chat {
|
model Chat {
|
||||||
@@ -65,7 +59,6 @@ model Chat {
|
|||||||
|
|
||||||
messages Message[]
|
messages Message[]
|
||||||
calls LlmCall[]
|
calls LlmCall[]
|
||||||
projectItems ProjectItem[]
|
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
@@ -121,7 +114,6 @@ model Search {
|
|||||||
|
|
||||||
title String?
|
title String?
|
||||||
query String?
|
query String?
|
||||||
queryNormalized String?
|
|
||||||
|
|
||||||
source SearchSource @default(exa)
|
source SearchSource @default(exa)
|
||||||
|
|
||||||
@@ -140,10 +132,8 @@ model Search {
|
|||||||
userId String?
|
userId String?
|
||||||
|
|
||||||
results SearchResult[]
|
results SearchResult[]
|
||||||
projectItems ProjectItem[]
|
|
||||||
|
|
||||||
@@index([updatedAt])
|
@@index([updatedAt])
|
||||||
@@index([queryNormalized, updatedAt])
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,40 +159,3 @@ model SearchResult {
|
|||||||
|
|
||||||
@@index([searchId, rank])
|
@@index([searchId, rank])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Project {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
kind ProjectKind @default(folder)
|
|
||||||
title String
|
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
userId String?
|
|
||||||
|
|
||||||
items ProjectItem[]
|
|
||||||
|
|
||||||
@@index([kind])
|
|
||||||
@@index([userId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model ProjectItem {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
|
||||||
projectId String
|
|
||||||
|
|
||||||
chat Chat? @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
|
||||||
chatId String?
|
|
||||||
|
|
||||||
search Search? @relation(fields: [searchId], references: [id], onDelete: Cascade)
|
|
||||||
searchId String?
|
|
||||||
|
|
||||||
@@unique([projectId, chatId])
|
|
||||||
@@unique([projectId, searchId])
|
|
||||||
@@index([projectId, createdAt])
|
|
||||||
@@index([chatId])
|
|
||||||
@@index([searchId])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { getModelCatalogSnapshot } from "./llm/model-catalog.js";
|
|||||||
import { openaiClient } from "./llm/providers.js";
|
import { openaiClient } from "./llm/providers.js";
|
||||||
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
||||||
import { exaClient } from "./search/exa.js";
|
import { exaClient } from "./search/exa.js";
|
||||||
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
|
|
||||||
import type { ChatAttachment } from "./llm/types.js";
|
import type { ChatAttachment } from "./llm/types.js";
|
||||||
|
|
||||||
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]);
|
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "hermes-agent"]);
|
||||||
@@ -400,15 +399,21 @@ type SearchRunRequest = z.infer<typeof SearchRunBody>;
|
|||||||
|
|
||||||
const activeChatStreams = new Map<string, ActiveSseStream>();
|
const activeChatStreams = new Map<string, ActiveSseStream>();
|
||||||
const activeSearchStreams = new Map<string, ActiveSseStream>();
|
const activeSearchStreams = new Map<string, ActiveSseStream>();
|
||||||
const STARRED_PROJECT_ID = "starred";
|
|
||||||
|
|
||||||
const starredProjectItemsSelect = {
|
function getErrorMessage(err: unknown) {
|
||||||
where: { projectId: STARRED_PROJECT_ID },
|
return err instanceof Error ? err.message : String(err);
|
||||||
select: { createdAt: true },
|
}
|
||||||
take: 1,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const chatSummarySelect = {
|
function compareUpdatedAtDesc(a: { updatedAt: Date | string }, b: { updatedAt: Date | string }) {
|
||||||
|
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listWorkspaceItems() {
|
||||||
|
const [chats, searches] = await Promise.all([
|
||||||
|
prisma.chat.findMany({
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: 100,
|
||||||
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
@@ -419,131 +424,18 @@ const chatSummarySelect = {
|
|||||||
lastUsedModel: true,
|
lastUsedModel: true,
|
||||||
additionalSystemPrompt: true,
|
additionalSystemPrompt: true,
|
||||||
enabledTools: true,
|
enabledTools: true,
|
||||||
projectItems: starredProjectItemsSelect,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const searchSummarySelect = {
|
|
||||||
id: true,
|
|
||||||
title: true,
|
|
||||||
query: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
projectItems: starredProjectItemsSelect,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function getErrorMessage(err: unknown) {
|
|
||||||
return err instanceof Error ? err.message : String(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareUpdatedAtDesc(a: { updatedAt: Date | string }, b: { updatedAt: Date | string }) {
|
|
||||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeStarFields(item: { projectItems?: Array<{ createdAt: Date }> }) {
|
|
||||||
const star = item.projectItems?.[0];
|
|
||||||
return {
|
|
||||||
starred: Boolean(star),
|
|
||||||
starredAt: star?.createdAt ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeChatLike<T extends Record<string, any>>(chat: T) {
|
|
||||||
const { projectItems: _projectItems, ...rest } = chat;
|
|
||||||
return {
|
|
||||||
...serializeProviderFields(rest),
|
|
||||||
...serializeStarFields(chat),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializeSearchLike<T extends Record<string, any>>(search: T) {
|
|
||||||
const { projectItems: _projectItems, queryNormalized: _queryNormalized, ...rest } = search;
|
|
||||||
return {
|
|
||||||
...rest,
|
|
||||||
...serializeStarFields(search),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureStarredProject() {
|
|
||||||
await prisma.project.upsert({
|
|
||||||
where: { id: STARRED_PROJECT_ID },
|
|
||||||
update: {},
|
|
||||||
create: {
|
|
||||||
id: STARRED_PROJECT_ID,
|
|
||||||
kind: "starred" as any,
|
|
||||||
title: "Starred",
|
|
||||||
},
|
},
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getChatSummary(chatId: string) {
|
|
||||||
const chat = await prisma.chat.findUnique({
|
|
||||||
where: { id: chatId },
|
|
||||||
select: chatSummarySelect,
|
|
||||||
});
|
|
||||||
return chat ? serializeChatLike(chat) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSearchSummary(searchId: string) {
|
|
||||||
const search = await prisma.search.findUnique({
|
|
||||||
where: { id: searchId },
|
|
||||||
select: searchSummarySelect,
|
|
||||||
});
|
|
||||||
return search ? serializeSearchLike(search) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setChatStarred(chatId: string, starred: boolean) {
|
|
||||||
const exists = await prisma.chat.findUnique({ where: { id: chatId }, select: { id: true } });
|
|
||||||
if (!exists) return null;
|
|
||||||
|
|
||||||
if (starred) {
|
|
||||||
await ensureStarredProject();
|
|
||||||
await prisma.projectItem.upsert({
|
|
||||||
where: { projectId_chatId: { projectId: STARRED_PROJECT_ID, chatId } },
|
|
||||||
update: {},
|
|
||||||
create: { projectId: STARRED_PROJECT_ID, chatId },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await prisma.projectItem.deleteMany({ where: { projectId: STARRED_PROJECT_ID, chatId } });
|
|
||||||
}
|
|
||||||
|
|
||||||
return getChatSummary(chatId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setSearchStarred(searchId: string, starred: boolean) {
|
|
||||||
const exists = await prisma.search.findUnique({ where: { id: searchId }, select: { id: true } });
|
|
||||||
if (!exists) return null;
|
|
||||||
|
|
||||||
if (starred) {
|
|
||||||
await ensureStarredProject();
|
|
||||||
await prisma.projectItem.upsert({
|
|
||||||
where: { projectId_searchId: { projectId: STARRED_PROJECT_ID, searchId } },
|
|
||||||
update: {},
|
|
||||||
create: { projectId: STARRED_PROJECT_ID, searchId },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await prisma.projectItem.deleteMany({ where: { projectId: STARRED_PROJECT_ID, searchId } });
|
|
||||||
}
|
|
||||||
|
|
||||||
return getSearchSummary(searchId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listWorkspaceItems() {
|
|
||||||
const [chats, searches] = await Promise.all([
|
|
||||||
prisma.chat.findMany({
|
|
||||||
orderBy: { updatedAt: "desc" },
|
|
||||||
take: 100,
|
|
||||||
select: chatSummarySelect,
|
|
||||||
}),
|
}),
|
||||||
prisma.search.findMany({
|
prisma.search.findMany({
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
take: 100,
|
take: 100,
|
||||||
select: searchSummarySelect,
|
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...chats.map((chat) => ({ type: "chat" as const, ...serializeChatLike(chat) })),
|
...chats.map((chat) => ({ type: "chat" as const, ...serializeProviderFields(chat) })),
|
||||||
...searches.map((search) => ({ type: "search" as const, ...serializeSearchLike(search) })),
|
...searches.map((search) => ({ type: "search" as const, ...search })),
|
||||||
].sort(compareUpdatedAtDesc);
|
].sort(compareUpdatedAtDesc);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,7 +622,6 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
|
|||||||
where: { id: searchId },
|
where: { id: searchId },
|
||||||
data: {
|
data: {
|
||||||
query,
|
query,
|
||||||
queryNormalized: normalizeSearchQuery(query),
|
|
||||||
title: normalizedTitle,
|
title: normalizedTitle,
|
||||||
requestId: searchResponse?.requestId ?? null,
|
requestId: searchResponse?.requestId ?? null,
|
||||||
rawResponse: searchResponse as any,
|
rawResponse: searchResponse as any,
|
||||||
@@ -751,15 +642,12 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
|
|||||||
|
|
||||||
const search = await prisma.search.findUnique({
|
const search = await prisma.search.findUnique({
|
||||||
where: { id: searchId },
|
where: { id: searchId },
|
||||||
include: {
|
include: { results: { orderBy: { rank: "asc" } } },
|
||||||
results: { orderBy: { rank: "asc" } },
|
|
||||||
projectItems: starredProjectItemsSelect,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!search) {
|
if (!search) {
|
||||||
stream.complete({ event: "error", data: { message: "search not found" } });
|
stream.complete({ event: "error", data: { message: "search not found" } });
|
||||||
} else {
|
} else {
|
||||||
stream.complete({ event: "done", data: { search: serializeSearchLike(search) } });
|
stream.complete({ event: "done", data: { search } });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = getErrorMessage(err);
|
const message = getErrorMessage(err);
|
||||||
@@ -768,7 +656,6 @@ async function executeSearchRunStream(searchId: string, body: SearchRunRequest,
|
|||||||
where: { id: searchId },
|
where: { id: searchId },
|
||||||
data: {
|
data: {
|
||||||
query,
|
query,
|
||||||
queryNormalized: normalizeSearchQuery(query),
|
|
||||||
title: normalizedTitle,
|
title: normalizedTitle,
|
||||||
latencyMs: Math.round(performance.now() - startedAt),
|
latencyMs: Math.round(performance.now() - startedAt),
|
||||||
error: message,
|
error: message,
|
||||||
@@ -819,9 +706,20 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
const chats = await prisma.chat.findMany({
|
const chats = await prisma.chat.findMany({
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
take: 100,
|
take: 100,
|
||||||
select: chatSummarySelect,
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
initiatedProvider: true,
|
||||||
|
initiatedModel: true,
|
||||||
|
lastUsedProvider: true,
|
||||||
|
lastUsedModel: true,
|
||||||
|
additionalSystemPrompt: true,
|
||||||
|
enabledTools: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return { chats: chats.map((chat) => serializeChatLike(chat)) };
|
return { chats: chats.map((chat) => serializeProviderFields(chat)) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/chats", async (req) => {
|
app.post("/v1/chats", async (req) => {
|
||||||
@@ -874,9 +772,20 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
select: chatSummarySelect,
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
initiatedProvider: true,
|
||||||
|
initiatedModel: true,
|
||||||
|
lastUsedProvider: true,
|
||||||
|
lastUsedModel: true,
|
||||||
|
additionalSystemPrompt: true,
|
||||||
|
enabledTools: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return { chat: serializeChatLike(chat) };
|
return { chat: serializeProviderFields(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch("/v1/chats/:chatId", async (req) => {
|
app.patch("/v1/chats/:chatId", async (req) => {
|
||||||
@@ -902,21 +811,23 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
if (updated.count === 0) return app.httpErrors.notFound("chat not found");
|
if (updated.count === 0) return app.httpErrors.notFound("chat not found");
|
||||||
|
|
||||||
const chat = await getChatSummary(chatId);
|
const chat = await prisma.chat.findUnique({
|
||||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
where: { id: chatId },
|
||||||
return { chat };
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
initiatedProvider: true,
|
||||||
|
initiatedModel: true,
|
||||||
|
lastUsedProvider: true,
|
||||||
|
lastUsedModel: true,
|
||||||
|
additionalSystemPrompt: true,
|
||||||
|
enabledTools: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch("/v1/chats/:chatId/star", async (req) => {
|
|
||||||
requireAdmin(req);
|
|
||||||
const Params = z.object({ chatId: z.string() });
|
|
||||||
const Body = z.object({ starred: z.boolean() });
|
|
||||||
const { chatId } = Params.parse(req.params);
|
|
||||||
const body = Body.parse(req.body ?? {});
|
|
||||||
|
|
||||||
const chat = await setChatStarred(chatId, body.starred);
|
|
||||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||||
return { chat };
|
return { chat: serializeProviderFields(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/chats/title/suggest", async (req) => {
|
app.post("/v1/chats/title/suggest", async (req) => {
|
||||||
@@ -929,24 +840,44 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const existing = await prisma.chat.findUnique({
|
const existing = await prisma.chat.findUnique({
|
||||||
where: { id: body.chatId },
|
where: { id: body.chatId },
|
||||||
select: chatSummarySelect,
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
initiatedProvider: true,
|
||||||
|
initiatedModel: true,
|
||||||
|
lastUsedProvider: true,
|
||||||
|
lastUsedModel: true,
|
||||||
|
additionalSystemPrompt: true,
|
||||||
|
enabledTools: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!existing) return app.httpErrors.notFound("chat not found");
|
if (!existing) return app.httpErrors.notFound("chat not found");
|
||||||
if (existing.title?.trim()) return { chat: serializeChatLike(existing) };
|
if (existing.title?.trim()) return { chat: serializeProviderFields(existing) };
|
||||||
|
|
||||||
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
||||||
const suggestedRaw = await generateChatTitle(body.content);
|
const suggestedRaw = await generateChatTitle(body.content);
|
||||||
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
||||||
|
|
||||||
await prisma.chat.updateMany({
|
const chat = await prisma.chat.update({
|
||||||
where: { id: body.chatId, title: existing.title },
|
where: { id: body.chatId },
|
||||||
data: { title },
|
data: { title },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
initiatedProvider: true,
|
||||||
|
initiatedModel: true,
|
||||||
|
lastUsedProvider: true,
|
||||||
|
lastUsedModel: true,
|
||||||
|
additionalSystemPrompt: true,
|
||||||
|
enabledTools: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const chat = await getChatSummary(body.chatId);
|
return { chat: serializeProviderFields(chat) };
|
||||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
|
||||||
|
|
||||||
return { chat };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete("/v1/chats/:chatId", async (req) => {
|
app.delete("/v1/chats/:chatId", async (req) => {
|
||||||
@@ -971,69 +902,24 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
const searches = await prisma.search.findMany({
|
const searches = await prisma.search.findMany({
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
take: 100,
|
take: 100,
|
||||||
select: searchSummarySelect,
|
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
|
||||||
});
|
});
|
||||||
return { searches: searches.map((search) => serializeSearchLike(search)) };
|
return { searches };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/searches", async (req) => {
|
app.post("/v1/searches", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
const Body = z.object({
|
const Body = z.object({ title: z.string().optional(), query: z.string().optional() });
|
||||||
title: z.string().optional(),
|
|
||||||
query: z.string().optional(),
|
|
||||||
reuseByQuery: z.boolean().optional(),
|
|
||||||
});
|
|
||||||
const body = Body.parse(req.body ?? {});
|
const body = Body.parse(req.body ?? {});
|
||||||
const title = body.title?.trim() || body.query?.trim()?.slice(0, 80);
|
const title = body.title?.trim() || body.query?.trim()?.slice(0, 80);
|
||||||
const query = body.query?.trim() || null;
|
const query = body.query?.trim() || null;
|
||||||
const queryNormalized = normalizeSearchQuery(query);
|
|
||||||
|
|
||||||
if (body.reuseByQuery && queryNormalized) {
|
|
||||||
const existing = await prisma.search.findFirst({
|
|
||||||
where: { queryNormalized },
|
|
||||||
orderBy: { updatedAt: "desc" },
|
|
||||||
select: {
|
|
||||||
...searchSummarySelect,
|
|
||||||
answerText: true,
|
|
||||||
_count: { select: { results: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
const { _count, answerText: _answerText, ...search } = existing;
|
|
||||||
return {
|
|
||||||
search: serializeSearchLike(search),
|
|
||||||
reused: true,
|
|
||||||
cacheHit: isFreshSearchCacheHit({
|
|
||||||
updatedAt: existing.updatedAt,
|
|
||||||
resultCount: _count.results,
|
|
||||||
answerText: existing.answerText,
|
|
||||||
isActive: activeSearchStreams.has(existing.id),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const search = await prisma.search.create({
|
const search = await prisma.search.create({
|
||||||
data: {
|
data: {
|
||||||
title: title || null,
|
title: title || null,
|
||||||
query,
|
query,
|
||||||
queryNormalized,
|
|
||||||
},
|
},
|
||||||
select: searchSummarySelect,
|
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
|
||||||
});
|
});
|
||||||
return { search: serializeSearchLike(search), reused: false, cacheHit: false };
|
|
||||||
});
|
|
||||||
|
|
||||||
app.patch("/v1/searches/:searchId/star", async (req) => {
|
|
||||||
requireAdmin(req);
|
|
||||||
const Params = z.object({ searchId: z.string() });
|
|
||||||
const Body = z.object({ starred: z.boolean() });
|
|
||||||
const { searchId } = Params.parse(req.params);
|
|
||||||
const body = Body.parse(req.body ?? {});
|
|
||||||
|
|
||||||
const search = await setSearchStarred(searchId, body.starred);
|
|
||||||
if (!search) return app.httpErrors.notFound("search not found");
|
|
||||||
return { search };
|
return { search };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1060,13 +946,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const search = await prisma.search.findUnique({
|
const search = await prisma.search.findUnique({
|
||||||
where: { id: searchId },
|
where: { id: searchId },
|
||||||
include: {
|
include: { results: { orderBy: { rank: "asc" } } },
|
||||||
results: { orderBy: { rank: "asc" } },
|
|
||||||
projectItems: starredProjectItemsSelect,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!search) return app.httpErrors.notFound("search not found");
|
if (!search) return app.httpErrors.notFound("search not found");
|
||||||
return { search: serializeSearchLike(search) };
|
return { search };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/searches/:searchId/chat", async (req) => {
|
app.post("/v1/searches/:searchId/chat", async (req) => {
|
||||||
@@ -1102,10 +985,21 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: chatSummarySelect,
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
initiatedProvider: true,
|
||||||
|
initiatedModel: true,
|
||||||
|
lastUsedProvider: true,
|
||||||
|
lastUsedModel: true,
|
||||||
|
additionalSystemPrompt: true,
|
||||||
|
enabledTools: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { chat: serializeChatLike(chat) };
|
return { chat: serializeProviderFields(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/searches/:searchId/run", async (req) => {
|
app.post("/v1/searches/:searchId/run", async (req) => {
|
||||||
@@ -1166,7 +1060,6 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
where: { id: searchId },
|
where: { id: searchId },
|
||||||
data: {
|
data: {
|
||||||
query,
|
query,
|
||||||
queryNormalized: normalizeSearchQuery(query),
|
|
||||||
title: normalizedTitle,
|
title: normalizedTitle,
|
||||||
requestId: searchResponse?.requestId ?? null,
|
requestId: searchResponse?.requestId ?? null,
|
||||||
rawResponse: searchResponse as any,
|
rawResponse: searchResponse as any,
|
||||||
@@ -1191,13 +1084,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const search = await prisma.search.findUnique({
|
const search = await prisma.search.findUnique({
|
||||||
where: { id: searchId },
|
where: { id: searchId },
|
||||||
include: {
|
include: { results: { orderBy: { rank: "asc" } } },
|
||||||
results: { orderBy: { rank: "asc" } },
|
|
||||||
projectItems: starredProjectItemsSelect,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!search) return app.httpErrors.notFound("search not found");
|
if (!search) return app.httpErrors.notFound("search not found");
|
||||||
return { search: serializeSearchLike(search) };
|
return { search };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
await prisma.search.update({
|
await prisma.search.update({
|
||||||
where: { id: searchId },
|
where: { id: searchId },
|
||||||
@@ -1252,14 +1142,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const chat = await prisma.chat.findUnique({
|
const chat = await prisma.chat.findUnique({
|
||||||
where: { id: chatId },
|
where: { id: chatId },
|
||||||
include: {
|
include: { messages: { orderBy: { createdAt: "asc" } }, calls: { orderBy: { createdAt: "desc" } } },
|
||||||
messages: { orderBy: { createdAt: "asc" } },
|
|
||||||
calls: { orderBy: { createdAt: "desc" } },
|
|
||||||
projectItems: starredProjectItemsSelect,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||||
return { chat: serializeChatLike(chat) };
|
return { chat: serializeProviderFields(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/chats/:chatId/messages", async (req) => {
|
app.post("/v1/chats/:chatId/messages", async (req) => {
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
export const SEARCH_QUERY_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
export function normalizeSearchQuery(value: string | null | undefined) {
|
|
||||||
const normalized = value?.trim().toLowerCase() ?? "";
|
|
||||||
return normalized || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasReusableSearchPayload(candidate: { resultCount: number; answerText?: string | null }) {
|
|
||||||
return candidate.resultCount > 0 || Boolean(candidate.answerText?.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isFreshSearchCacheHit(
|
|
||||||
candidate: {
|
|
||||||
updatedAt: Date | string;
|
|
||||||
resultCount: number;
|
|
||||||
answerText?: string | null;
|
|
||||||
isActive?: boolean;
|
|
||||||
},
|
|
||||||
now = new Date(),
|
|
||||||
ttlMs = SEARCH_QUERY_CACHE_TTL_MS
|
|
||||||
) {
|
|
||||||
if (candidate.isActive) return false;
|
|
||||||
if (!hasReusableSearchPayload(candidate)) return false;
|
|
||||||
|
|
||||||
const updatedAtMs = new Date(candidate.updatedAt).getTime();
|
|
||||||
if (!Number.isFinite(updatedAtMs)) return false;
|
|
||||||
|
|
||||||
return now.getTime() - updatedAtMs <= ttlMs;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import { SEARCH_QUERY_CACHE_TTL_MS, isFreshSearchCacheHit, normalizeSearchQuery } from "../src/search-cache.js";
|
|
||||||
|
|
||||||
test("normalizeSearchQuery trims and lowercases query text", () => {
|
|
||||||
assert.equal(normalizeSearchQuery(" Bitcoin PRICE "), "bitcoin price");
|
|
||||||
assert.equal(normalizeSearchQuery(" "), null);
|
|
||||||
assert.equal(normalizeSearchQuery(null), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("isFreshSearchCacheHit requires fresh persisted payload and no active stream", () => {
|
|
||||||
const now = new Date("2026-05-31T12:00:00.000Z");
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
isFreshSearchCacheHit({ updatedAt: new Date(now.getTime() - SEARCH_QUERY_CACHE_TTL_MS + 1), resultCount: 1 }, now),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
isFreshSearchCacheHit({ updatedAt: new Date(now.getTime() - SEARCH_QUERY_CACHE_TTL_MS - 1), resultCount: 1 }, now),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 0, answerText: "" }, now), false);
|
|
||||||
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 0, answerText: "answer" }, now), true);
|
|
||||||
assert.equal(isFreshSearchCacheHit({ updatedAt: now, resultCount: 1, isActive: true }, now), false);
|
|
||||||
});
|
|
||||||
@@ -60,22 +60,6 @@ export class SybilApiClient {
|
|||||||
return data.chat;
|
return data.chat;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateChatTitle(chatId: string, title: string) {
|
|
||||||
const data = await this.request<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
body: { title },
|
|
||||||
});
|
|
||||||
return data.chat;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateChatStar(chatId: string, starred: boolean) {
|
|
||||||
const data = await this.request<{ chat: ChatSummary }>(`/v1/chats/${chatId}/star`, {
|
|
||||||
method: "PATCH",
|
|
||||||
body: { starred },
|
|
||||||
});
|
|
||||||
return data.chat;
|
|
||||||
}
|
|
||||||
|
|
||||||
async suggestChatTitle(body: { chatId: string; content: string }) {
|
async suggestChatTitle(body: { chatId: string; content: string }) {
|
||||||
const data = await this.request<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
|
const data = await this.request<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -106,14 +90,6 @@ export class SybilApiClient {
|
|||||||
return data.search;
|
return data.search;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSearchStar(searchId: string, starred: boolean) {
|
|
||||||
const data = await this.request<{ search: SearchSummary }>(`/v1/searches/${searchId}/star`, {
|
|
||||||
method: "PATCH",
|
|
||||||
body: { starred },
|
|
||||||
});
|
|
||||||
return data.search;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteSearch(searchId: string) {
|
async deleteSearch(searchId: string) {
|
||||||
await this.request<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
|
await this.request<{ deleted: true }>(`/v1/searches/${searchId}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|||||||
185
tui/src/index.ts
185
tui/src/index.ts
@@ -20,8 +20,6 @@ type SidebarItem = SidebarSelection & {
|
|||||||
title: string;
|
title: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
starred: boolean;
|
|
||||||
starredAt: string | null;
|
|
||||||
initiatedProvider: Provider | null;
|
initiatedProvider: Provider | null;
|
||||||
initiatedModel: string | null;
|
initiatedModel: string | null;
|
||||||
lastUsedProvider: Provider | null;
|
lastUsedProvider: Provider | null;
|
||||||
@@ -133,8 +131,6 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
|||||||
title: getChatTitle(chat),
|
title: getChatTitle(chat),
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
starred: chat.starred,
|
|
||||||
starredAt: chat.starredAt,
|
|
||||||
initiatedProvider: chat.initiatedProvider,
|
initiatedProvider: chat.initiatedProvider,
|
||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
@@ -149,8 +145,6 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
|||||||
title: getSearchTitle(search),
|
title: getSearchTitle(search),
|
||||||
updatedAt: search.updatedAt,
|
updatedAt: search.updatedAt,
|
||||||
createdAt: search.createdAt,
|
createdAt: search.createdAt,
|
||||||
starred: search.starred,
|
|
||||||
starredAt: search.starredAt,
|
|
||||||
initiatedProvider: null,
|
initiatedProvider: null,
|
||||||
initiatedModel: null,
|
initiatedModel: null,
|
||||||
lastUsedProvider: null,
|
lastUsedProvider: null,
|
||||||
@@ -260,7 +254,6 @@ async function main() {
|
|||||||
let renderedSidebarItems: SidebarItem[] = [];
|
let renderedSidebarItems: SidebarItem[] = [];
|
||||||
let renderedSidebarLines: string[] = [];
|
let renderedSidebarLines: string[] = [];
|
||||||
let suppressedSidebarSelectEvents = 0;
|
let suppressedSidebarSelectEvents = 0;
|
||||||
let isRenamePromptOpen = false;
|
|
||||||
|
|
||||||
const screen = blessed.screen({
|
const screen = blessed.screen({
|
||||||
smartCSR: true,
|
smartCSR: true,
|
||||||
@@ -368,26 +361,6 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const renamePrompt = (blessed as any).prompt({
|
|
||||||
parent: screen,
|
|
||||||
label: " Rename chat ",
|
|
||||||
border: "line",
|
|
||||||
tags: true,
|
|
||||||
keys: true,
|
|
||||||
vi: true,
|
|
||||||
mouse: true,
|
|
||||||
top: "center",
|
|
||||||
left: "center",
|
|
||||||
width: "50%",
|
|
||||||
height: "shrink",
|
|
||||||
hidden: true,
|
|
||||||
style: {
|
|
||||||
border: { fg: "cyan" },
|
|
||||||
label: { fg: "cyan" },
|
|
||||||
fg: "white",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const focusables = [sidebar, transcript, composer] as const;
|
const focusables = [sidebar, transcript, composer] as const;
|
||||||
|
|
||||||
function getTranscriptViewportHeight() {
|
function getTranscriptViewportHeight() {
|
||||||
@@ -527,13 +500,12 @@ async function main() {
|
|||||||
? ["No chats/searches yet. Press n or /. "]
|
? ["No chats/searches yet. Press n or /. "]
|
||||||
: items.map((item) => {
|
: items.map((item) => {
|
||||||
const kind = item.kind === "chat" ? "C" : "S";
|
const kind = item.kind === "chat" ? "C" : "S";
|
||||||
const star = item.starred ? "{yellow-fg}★{/yellow-fg} " : " ";
|
|
||||||
const title = truncate(item.title, 36);
|
const title = truncate(item.title, 36);
|
||||||
const initiatedLabel =
|
const initiatedLabel =
|
||||||
item.kind === "chat" && item.initiatedModel
|
item.kind === "chat" && item.initiatedModel
|
||||||
? ` | ${getProviderLabel(item.initiatedProvider)} ${truncate(item.initiatedModel, 16)}`
|
? ` | ${getProviderLabel(item.initiatedProvider)} ${truncate(item.initiatedModel, 16)}`
|
||||||
: "";
|
: "";
|
||||||
return `${star}${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`;
|
return `${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const linesChanged =
|
const linesChanged =
|
||||||
@@ -708,7 +680,7 @@ async function main() {
|
|||||||
const top = `{bold}${escapeTags(getSelectedTitle())}{/bold} {gray-fg}- Sybil TUI${modeLabel}${isSearchMode ? " • Exa Search" : ""}{/gray-fg}`;
|
const top = `{bold}${escapeTags(getSelectedTitle())}{/bold} {gray-fg}- Sybil TUI${modeLabel}${isSearchMode ? " • Exa Search" : ""}{/gray-fg}`;
|
||||||
|
|
||||||
let controls =
|
let controls =
|
||||||
"{gray-fg}Controls:{/gray-fg} [tab] focus [esc] command mode [↑/↓] highlight [enter] send/select [n] new chat [/] new search [s] star [r] rename [d] delete [C-r] refresh [q] quit";
|
"{gray-fg}Controls:{/gray-fg} [tab] focus [esc] command mode [↑/↓] highlight [enter] send/select [n] new chat [/] new search [d] delete [q] quit";
|
||||||
if (!isSearchMode) {
|
if (!isSearchMode) {
|
||||||
controls += `\n{gray-fg}Model:{/gray-fg} provider {cyan-fg}${provider}{/cyan-fg} [p] model {cyan-fg}${escapeTags(model)}{/cyan-fg} [m]`;
|
controls += `\n{gray-fg}Model:{/gray-fg} provider {cyan-fg}${provider}{/cyan-fg} [p] model {cyan-fg}${escapeTags(model)}{/cyan-fg} [m]`;
|
||||||
controls += providerModelOptions.length === 0 ? " {red-fg}(no models){/red-fg}" : "";
|
controls += providerModelOptions.length === 0 ? " {red-fg}(no models){/red-fg}" : "";
|
||||||
@@ -870,27 +842,6 @@ async function main() {
|
|||||||
composer.readInput();
|
composer.readInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldIgnoreGlobalShortcut() {
|
|
||||||
return isRenamePromptOpen || isTextInputFocused(screen, composer);
|
|
||||||
}
|
|
||||||
|
|
||||||
function promptForChatTitle(currentTitle: string) {
|
|
||||||
isRenamePromptOpen = true;
|
|
||||||
updateUI();
|
|
||||||
return new Promise<string | null>((resolve) => {
|
|
||||||
renamePrompt.input("Title:", currentTitle, (err: Error | null, value: string | null) => {
|
|
||||||
isRenamePromptOpen = false;
|
|
||||||
renamePrompt.hide();
|
|
||||||
screen.render();
|
|
||||||
if (err || value === null || value === undefined) {
|
|
||||||
resolve(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function cycleFocus(step: 1 | -1) {
|
function cycleFocus(step: 1 | -1) {
|
||||||
const focused = screen.focused;
|
const focused = screen.focused;
|
||||||
const currentIndex = focusables.findIndex((node) => node === focused);
|
const currentIndex = focusables.findIndex((node) => node === focused);
|
||||||
@@ -959,20 +910,10 @@ async function main() {
|
|||||||
pendingTitleGeneration.add(chatId);
|
pendingTitleGeneration.add(chatId);
|
||||||
try {
|
try {
|
||||||
const updated = await api.suggestChatTitle({ chatId, content });
|
const updated = await api.suggestChatTitle({ chatId, content });
|
||||||
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
|
chats = chats.map((chat) => (chat.id === updated.id ? { ...chat, title: updated.title, updatedAt: updated.updatedAt } : chat));
|
||||||
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
||||||
if (selectedChat?.id === updated.id) {
|
if (selectedChat?.id === updated.id) {
|
||||||
selectedChat = {
|
selectedChat = { ...selectedChat, title: updated.title, updatedAt: updated.updatedAt };
|
||||||
...selectedChat,
|
|
||||||
title: updated.title,
|
|
||||||
updatedAt: updated.updatedAt,
|
|
||||||
starred: updated.starred,
|
|
||||||
starredAt: updated.starredAt,
|
|
||||||
initiatedProvider: updated.initiatedProvider,
|
|
||||||
initiatedModel: updated.initiatedModel,
|
|
||||||
lastUsedProvider: updated.lastUsedProvider,
|
|
||||||
lastUsedModel: updated.lastUsedModel,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
updateUI();
|
updateUI();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1023,8 +964,6 @@ async function main() {
|
|||||||
title: chat.title,
|
title: chat.title,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
starred: chat.starred,
|
|
||||||
starredAt: chat.starredAt,
|
|
||||||
initiatedProvider: chat.initiatedProvider,
|
initiatedProvider: chat.initiatedProvider,
|
||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
@@ -1201,8 +1140,6 @@ async function main() {
|
|||||||
query,
|
query,
|
||||||
createdAt: nowIso,
|
createdAt: nowIso,
|
||||||
updatedAt: nowIso,
|
updatedAt: nowIso,
|
||||||
starred: false,
|
|
||||||
starredAt: null,
|
|
||||||
requestId: null,
|
requestId: null,
|
||||||
latencyMs: null,
|
latencyMs: null,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -1365,88 +1302,6 @@ async function main() {
|
|||||||
await refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true });
|
await refreshCollections({ loadSelection: true, scrollToBottomOnLoad: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRenameSelection() {
|
|
||||||
if (!selectedItem || selectedItem.kind !== "chat") return;
|
|
||||||
|
|
||||||
const chatId = selectedItem.id;
|
|
||||||
const summary = chats.find((chat) => chat.id === chatId);
|
|
||||||
const currentTitle = selectedChat?.id === chatId ? getChatTitle(selectedChat, selectedChat.messages) : summary ? getChatTitle(summary) : "New chat";
|
|
||||||
const value = await promptForChatTitle(currentTitle);
|
|
||||||
const title = value?.trim();
|
|
||||||
if (!title) {
|
|
||||||
updateUI();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setError(null);
|
|
||||||
const updated = await api.updateChatTitle(chatId, title);
|
|
||||||
chats = [updated, ...chats.filter((chat) => chat.id !== updated.id)];
|
|
||||||
workspaceItems = upsertWorkspaceItem(workspaceItems, chatWorkspaceItem(updated));
|
|
||||||
if (selectedChat?.id === updated.id) {
|
|
||||||
selectedChat = {
|
|
||||||
...selectedChat,
|
|
||||||
title: updated.title,
|
|
||||||
updatedAt: updated.updatedAt,
|
|
||||||
initiatedProvider: updated.initiatedProvider,
|
|
||||||
initiatedModel: updated.initiatedModel,
|
|
||||||
lastUsedProvider: updated.lastUsedProvider,
|
|
||||||
lastUsedModel: updated.lastUsedModel,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
updateUI();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleToggleStarSelection() {
|
|
||||||
if (!selectedItem) return;
|
|
||||||
|
|
||||||
const currentItem = getSidebarItems().find((item) => item.kind === selectedItem?.kind && item.id === selectedItem?.id);
|
|
||||||
const nextStarred = !currentItem?.starred;
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
if (selectedItem.kind === "chat") {
|
|
||||||
const updated = await api.updateChatStar(selectedItem.id, nextStarred);
|
|
||||||
chats = chats.map((chat) => (chat.id === updated.id ? updated : chat));
|
|
||||||
if (!chats.some((chat) => chat.id === updated.id)) chats = [updated, ...chats];
|
|
||||||
workspaceItems = workspaceItems.map((item) => (item.type === "chat" && item.id === updated.id ? chatWorkspaceItem(updated) : item));
|
|
||||||
if (!workspaceItems.some((item) => item.type === "chat" && item.id === updated.id)) {
|
|
||||||
workspaceItems = [chatWorkspaceItem(updated), ...workspaceItems];
|
|
||||||
}
|
|
||||||
if (selectedChat?.id === updated.id) {
|
|
||||||
selectedChat = {
|
|
||||||
...selectedChat,
|
|
||||||
title: updated.title,
|
|
||||||
updatedAt: updated.updatedAt,
|
|
||||||
starred: updated.starred,
|
|
||||||
starredAt: updated.starredAt,
|
|
||||||
initiatedProvider: updated.initiatedProvider,
|
|
||||||
initiatedModel: updated.initiatedModel,
|
|
||||||
lastUsedProvider: updated.lastUsedProvider,
|
|
||||||
lastUsedModel: updated.lastUsedModel,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const updated = await api.updateSearchStar(selectedItem.id, nextStarred);
|
|
||||||
searches = searches.map((search) => (search.id === updated.id ? updated : search));
|
|
||||||
if (!searches.some((search) => search.id === updated.id)) searches = [updated, ...searches];
|
|
||||||
workspaceItems = workspaceItems.map((item) => (item.type === "search" && item.id === updated.id ? searchWorkspaceItem(updated) : item));
|
|
||||||
if (!workspaceItems.some((item) => item.type === "search" && item.id === updated.id)) {
|
|
||||||
workspaceItems = [searchWorkspaceItem(updated), ...workspaceItems];
|
|
||||||
}
|
|
||||||
if (selectedSearch?.id === updated.id) {
|
|
||||||
selectedSearch = {
|
|
||||||
...selectedSearch,
|
|
||||||
title: updated.title,
|
|
||||||
query: updated.query,
|
|
||||||
updatedAt: updated.updatedAt,
|
|
||||||
starred: updated.starred,
|
|
||||||
starredAt: updated.starredAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateUI();
|
|
||||||
}
|
|
||||||
|
|
||||||
function cycleProvider() {
|
function cycleProvider() {
|
||||||
const visibleProviders = getVisibleProviders(modelCatalog);
|
const visibleProviders = getVisibleProviders(modelCatalog);
|
||||||
const cycleProviders = visibleProviders.length ? visibleProviders : BASE_PROVIDERS;
|
const cycleProviders = visibleProviders.length ? visibleProviders : BASE_PROVIDERS;
|
||||||
@@ -1532,18 +1387,18 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["q"], () => {
|
screen.key(["q"], () => {
|
||||||
if (shouldIgnoreGlobalShortcut()) return;
|
if (isTextInputFocused(screen, composer)) return;
|
||||||
screen.destroy();
|
screen.destroy();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["tab"], () => {
|
screen.key(["tab"], () => {
|
||||||
if (shouldIgnoreGlobalShortcut()) return;
|
if (isTextInputFocused(screen, composer)) return;
|
||||||
cycleFocus(1);
|
cycleFocus(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["S-tab", "backtab"], () => {
|
screen.key(["S-tab", "backtab"], () => {
|
||||||
if (shouldIgnoreGlobalShortcut()) return;
|
if (isTextInputFocused(screen, composer)) return;
|
||||||
cycleFocus(-1);
|
cycleFocus(-1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1560,50 +1415,36 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["n"], () => {
|
screen.key(["n"], () => {
|
||||||
if (shouldIgnoreGlobalShortcut()) return;
|
if (isTextInputFocused(screen, composer)) return;
|
||||||
handleCreateChat();
|
handleCreateChat();
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["/"], () => {
|
screen.key(["/"], () => {
|
||||||
if (shouldIgnoreGlobalShortcut()) return;
|
if (isTextInputFocused(screen, composer)) return;
|
||||||
handleCreateSearch();
|
handleCreateSearch();
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["d"], () => {
|
screen.key(["d"], () => {
|
||||||
if (shouldIgnoreGlobalShortcut()) return;
|
if (isTextInputFocused(screen, composer)) return;
|
||||||
void runAction(async () => {
|
void runAction(async () => {
|
||||||
await handleDeleteSelection();
|
await handleDeleteSelection();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["s"], () => {
|
|
||||||
if (shouldIgnoreGlobalShortcut()) return;
|
|
||||||
void runAction(async () => {
|
|
||||||
await handleToggleStarSelection();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
screen.key(["p"], () => {
|
screen.key(["p"], () => {
|
||||||
if (shouldIgnoreGlobalShortcut()) return;
|
if (isTextInputFocused(screen, composer)) return;
|
||||||
if (getIsSearchMode() || isSending) return;
|
if (getIsSearchMode() || isSending) return;
|
||||||
cycleProvider();
|
cycleProvider();
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["m"], () => {
|
screen.key(["m"], () => {
|
||||||
if (shouldIgnoreGlobalShortcut()) return;
|
if (isTextInputFocused(screen, composer)) return;
|
||||||
if (getIsSearchMode() || isSending) return;
|
if (getIsSearchMode() || isSending) return;
|
||||||
cycleModel();
|
cycleModel();
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["r"], () => {
|
screen.key(["r"], () => {
|
||||||
if (shouldIgnoreGlobalShortcut()) return;
|
if (isTextInputFocused(screen, composer)) return;
|
||||||
void runAction(async () => {
|
|
||||||
await handleRenameSelection();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
screen.key(["C-r"], () => {
|
|
||||||
if (shouldIgnoreGlobalShortcut()) return;
|
|
||||||
void runAction(async () => {
|
void runAction(async () => {
|
||||||
await refreshCollections({ loadSelection: true });
|
await refreshCollections({ loadSelection: true });
|
||||||
await refreshModels();
|
await refreshModels();
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ export type ChatSummary = {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
starred: boolean;
|
|
||||||
starredAt: string | null;
|
|
||||||
initiatedProvider: Provider | null;
|
initiatedProvider: Provider | null;
|
||||||
initiatedModel: string | null;
|
initiatedModel: string | null;
|
||||||
lastUsedProvider: Provider | null;
|
lastUsedProvider: Provider | null;
|
||||||
@@ -29,8 +27,6 @@ export type SearchSummary = {
|
|||||||
query: string | null;
|
query: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
starred: boolean;
|
|
||||||
starredAt: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChatWorkspaceItem = ChatSummary & {
|
export type ChatWorkspaceItem = ChatSummary & {
|
||||||
@@ -70,8 +66,6 @@ export type ChatDetail = {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
starred: boolean;
|
|
||||||
starredAt: string | null;
|
|
||||||
initiatedProvider: Provider | null;
|
initiatedProvider: Provider | null;
|
||||||
initiatedModel: string | null;
|
initiatedModel: string | null;
|
||||||
lastUsedProvider: Provider | null;
|
lastUsedProvider: Provider | null;
|
||||||
@@ -101,8 +95,6 @@ export type SearchDetail = {
|
|||||||
query: string | null;
|
query: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
starred: boolean;
|
|
||||||
starredAt: string | null;
|
|
||||||
requestId: string | null;
|
requestId: string | null;
|
||||||
latencyMs: number | null;
|
latencyMs: number | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|||||||
@@ -3,18 +3,12 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||||
<meta name="description" content="Sybil chat and search workspace" />
|
|
||||||
<meta name="application-name" content="Sybil" />
|
|
||||||
<meta name="theme-color" content="#0f172a" />
|
<meta name="theme-color" content="#0f172a" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Sybil" />
|
<meta name="apple-mobile-web-app-title" content="Sybil" />
|
||||||
<meta name="format-detection" content="telephone=no" />
|
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
|
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
|
|
||||||
<link rel="search" type="application/opensearchdescription+xml" title="Sybil Search" href="/opensearch.xml" />
|
<link rel="search" type="application/opensearchdescription+xml" title="Sybil Search" href="/opensearch.xml" />
|
||||||
<title>Sybil</title>
|
<title>Sybil</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 258 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 258 KiB |
@@ -1,32 +1,9 @@
|
|||||||
{
|
{
|
||||||
"id": "/",
|
|
||||||
"name": "Sybil",
|
"name": "Sybil",
|
||||||
"short_name": "Sybil",
|
"short_name": "Sybil",
|
||||||
"description": "Sybil chat and search workspace",
|
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "fullscreen",
|
"display": "standalone",
|
||||||
"display_override": ["fullscreen", "standalone"],
|
"background_color": "#ffffff",
|
||||||
"background_color": "#0b0718",
|
"theme_color": "#0f172a"
|
||||||
"theme_color": "#0f172a",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "any"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/icons/icon-maskable-512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
self.addEventListener("install", () => {
|
|
||||||
self.skipWaiting();
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener("activate", (event) => {
|
|
||||||
event.waitUntil(self.clients.claim());
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener("fetch", (event) => {
|
|
||||||
if (event.request.mode !== "navigate") return;
|
|
||||||
event.respondWith(fetch(event.request));
|
|
||||||
});
|
|
||||||
698
web/src/App.tsx
698
web/src/App.tsx
@@ -7,13 +7,11 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Pencil,
|
|
||||||
Plus,
|
Plus,
|
||||||
Rabbit,
|
Rabbit,
|
||||||
Search,
|
Search,
|
||||||
SendHorizontal,
|
SendHorizontal,
|
||||||
Settings2,
|
Settings2,
|
||||||
Star,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-preact";
|
} from "lucide-preact";
|
||||||
@@ -42,9 +40,6 @@ import {
|
|||||||
runCompletionStream,
|
runCompletionStream,
|
||||||
runSearchStream,
|
runSearchStream,
|
||||||
suggestChatTitle,
|
suggestChatTitle,
|
||||||
updateChatTitle,
|
|
||||||
updateChatStar,
|
|
||||||
updateSearchStar,
|
|
||||||
updateChatSettings,
|
updateChatSettings,
|
||||||
getMessageAttachments,
|
getMessageAttachments,
|
||||||
type ChatAttachment,
|
type ChatAttachment,
|
||||||
@@ -70,8 +65,6 @@ type SidebarItem = SidebarSelection & {
|
|||||||
title: string;
|
title: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
starred: boolean;
|
|
||||||
starredAt: string | null;
|
|
||||||
initiatedProvider: Provider | null;
|
initiatedProvider: Provider | null;
|
||||||
initiatedModel: string | null;
|
initiatedModel: string | null;
|
||||||
lastUsedProvider: Provider | null;
|
lastUsedProvider: Provider | null;
|
||||||
@@ -82,9 +75,6 @@ type ContextMenuState = {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
};
|
};
|
||||||
type RenameChatDialogState = {
|
|
||||||
chatId: string;
|
|
||||||
};
|
|
||||||
type PendingChatState = {
|
type PendingChatState = {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
};
|
};
|
||||||
@@ -673,8 +663,6 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
|||||||
title: getChatTitle(chat),
|
title: getChatTitle(chat),
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
starred: chat.starred,
|
|
||||||
starredAt: chat.starredAt,
|
|
||||||
initiatedProvider: chat.initiatedProvider,
|
initiatedProvider: chat.initiatedProvider,
|
||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
@@ -689,8 +677,6 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
|||||||
title: getSearchTitle(search),
|
title: getSearchTitle(search),
|
||||||
updatedAt: search.updatedAt,
|
updatedAt: search.updatedAt,
|
||||||
createdAt: search.createdAt,
|
createdAt: search.createdAt,
|
||||||
starred: search.starred,
|
|
||||||
starredAt: search.starredAt,
|
|
||||||
initiatedProvider: null,
|
initiatedProvider: null,
|
||||||
initiatedModel: null,
|
initiatedModel: null,
|
||||||
lastUsedProvider: null,
|
lastUsedProvider: null,
|
||||||
@@ -742,13 +728,7 @@ function getSidebarSectionLabel(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSidebarSections(items: SidebarItem[]) {
|
function buildSidebarSections(items: SidebarItem[]) {
|
||||||
const starred = items
|
return items.reduce<Array<{ label: string; items: SidebarItem[] }>>((sections, item) => {
|
||||||
.filter((item) => item.starred)
|
|
||||||
.sort((a, b) => new Date(b.starredAt ?? b.updatedAt).getTime() - new Date(a.starredAt ?? a.updatedAt).getTime());
|
|
||||||
const unstarred = items.filter((item) => !item.starred);
|
|
||||||
|
|
||||||
const sections = starred.length ? [{ label: "STARRED", items: starred }] : [];
|
|
||||||
return unstarred.reduce<Array<{ label: string; items: SidebarItem[] }>>((sections, item) => {
|
|
||||||
const label = getSidebarSectionLabel(item.updatedAt);
|
const label = getSidebarSectionLabel(item.updatedAt);
|
||||||
const section = sections.find((candidate) => candidate.label === label);
|
const section = sections.find((candidate) => candidate.label === label);
|
||||||
if (section) {
|
if (section) {
|
||||||
@@ -757,7 +737,7 @@ function buildSidebarSections(items: SidebarItem[]) {
|
|||||||
sections.push({ label, items: [item] });
|
sections.push({ label, items: [item] });
|
||||||
}
|
}
|
||||||
return sections;
|
return sections;
|
||||||
}, sections);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -815,27 +795,13 @@ export default function App() {
|
|||||||
const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false);
|
const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false);
|
||||||
const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null);
|
const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [renameChatDialog, setRenameChatDialog] = useState<RenameChatDialogState | null>(null);
|
|
||||||
const [renameChatDraft, setRenameChatDraft] = useState("");
|
|
||||||
const [renameChatError, setRenameChatError] = useState<string | null>(null);
|
|
||||||
const [isRenamingChat, setIsRenamingChat] = useState(false);
|
|
||||||
const [isChatSettingsOpen, setIsChatSettingsOpen] = useState(false);
|
const [isChatSettingsOpen, setIsChatSettingsOpen] = useState(false);
|
||||||
const [isSavingChatSettings, setIsSavingChatSettings] = useState(false);
|
|
||||||
const [isTogglingChatSettingsStar, setIsTogglingChatSettingsStar] = useState(false);
|
|
||||||
const [chatSettingsError, setChatSettingsError] = useState<string | null>(null);
|
|
||||||
const [draftChatTitle, setDraftChatTitle] = useState("");
|
|
||||||
const [chatSettingsTitleDraft, setChatSettingsTitleDraft] = useState("");
|
|
||||||
const [chatSettingsProviderDraft, setChatSettingsProviderDraft] = useState<Provider>("openai");
|
|
||||||
const [chatSettingsModelDraft, setChatSettingsModelDraft] = useState("");
|
|
||||||
const [chatSettingsPromptDraft, setChatSettingsPromptDraft] = useState("");
|
|
||||||
const [chatSettingsEnabledToolsDraft, setChatSettingsEnabledToolsDraft] = useState<string[]>([]);
|
|
||||||
const [additionalSystemPrompt, setAdditionalSystemPrompt] = useState("");
|
const [additionalSystemPrompt, setAdditionalSystemPrompt] = useState("");
|
||||||
const [enabledTools, setEnabledTools] = useState<string[]>([]);
|
const [enabledTools, setEnabledTools] = useState<string[]>([]);
|
||||||
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
|
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
|
||||||
const transcriptContainerRef = useRef<HTMLDivElement>(null);
|
const transcriptContainerRef = useRef<HTMLDivElement>(null);
|
||||||
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 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[]>([]);
|
||||||
@@ -957,15 +923,6 @@ export default function App() {
|
|||||||
setComposer("");
|
setComposer("");
|
||||||
setPendingAttachments([]);
|
setPendingAttachments([]);
|
||||||
setIsChatSettingsOpen(false);
|
setIsChatSettingsOpen(false);
|
||||||
setIsSavingChatSettings(false);
|
|
||||||
setIsTogglingChatSettingsStar(false);
|
|
||||||
setChatSettingsError(null);
|
|
||||||
setDraftChatTitle("");
|
|
||||||
setChatSettingsTitleDraft("");
|
|
||||||
setChatSettingsProviderDraft("openai");
|
|
||||||
setChatSettingsModelDraft("");
|
|
||||||
setChatSettingsPromptDraft("");
|
|
||||||
setChatSettingsEnabledToolsDraft([]);
|
|
||||||
setAdditionalSystemPrompt("");
|
setAdditionalSystemPrompt("");
|
||||||
setEnabledTools([]);
|
setEnabledTools([]);
|
||||||
setIsQuickQuestionOpen(false);
|
setIsQuickQuestionOpen(false);
|
||||||
@@ -974,11 +931,6 @@ export default function App() {
|
|||||||
setQuickSubmittedModelSelection(null);
|
setQuickSubmittedModelSelection(null);
|
||||||
setQuickQuestionMessages([]);
|
setQuickQuestionMessages([]);
|
||||||
setQuickQuestionError(null);
|
setQuickQuestionError(null);
|
||||||
setContextMenu(null);
|
|
||||||
setRenameChatDialog(null);
|
|
||||||
setRenameChatDraft("");
|
|
||||||
setRenameChatError(null);
|
|
||||||
setIsRenamingChat(false);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1149,10 +1101,6 @@ export default function App() {
|
|||||||
|
|
||||||
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
|
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
|
||||||
const quickProviderModelOptions = useMemo(() => getModelOptions(modelCatalog, quickProvider), [modelCatalog, quickProvider]);
|
const quickProviderModelOptions = useMemo(() => getModelOptions(modelCatalog, quickProvider), [modelCatalog, quickProvider]);
|
||||||
const chatSettingsProviderModelOptions = useMemo(
|
|
||||||
() => getModelOptions(modelCatalog, chatSettingsProviderDraft),
|
|
||||||
[chatSettingsProviderDraft, modelCatalog]
|
|
||||||
);
|
|
||||||
const providerOptions = useMemo(() => getVisibleProviders(modelCatalog), [modelCatalog]);
|
const providerOptions = useMemo(() => getVisibleProviders(modelCatalog), [modelCatalog]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1371,7 +1319,11 @@ export default function App() {
|
|||||||
}, [draftKind, selectedChat, selectedChatSummary, selectedItem]);
|
}, [draftKind, selectedChat, selectedChatSummary, selectedItem]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (draftKind === "chat") return;
|
if (draftKind === "chat") {
|
||||||
|
setAdditionalSystemPrompt("");
|
||||||
|
setEnabledTools(getDefaultEnabledTools(availableChatTools));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (selectedItem?.kind !== "chat") return;
|
if (selectedItem?.kind !== "chat") return;
|
||||||
const chat = selectedChat?.id === selectedItem.id ? selectedChat : selectedChatSummary;
|
const chat = selectedChat?.id === selectedItem.id ? selectedChat : selectedChatSummary;
|
||||||
if (!chat) return;
|
if (!chat) return;
|
||||||
@@ -1380,7 +1332,7 @@ export default function App() {
|
|||||||
}, [availableChatTools, draftKind, selectedChat, selectedChatSummary, selectedItem]);
|
}, [availableChatTools, draftKind, selectedChat, selectedChatSummary, selectedItem]);
|
||||||
|
|
||||||
const selectedTitle = useMemo(() => {
|
const selectedTitle = useMemo(() => {
|
||||||
if (draftKind === "chat") return draftChatTitle.trim() || "New chat";
|
if (draftKind === "chat") return "New chat";
|
||||||
if (draftKind === "search") return "New search";
|
if (draftKind === "search") return "New search";
|
||||||
if (!selectedItem) return "Sybil";
|
if (!selectedItem) return "Sybil";
|
||||||
if (selectedItem.kind === "chat") {
|
if (selectedItem.kind === "chat") {
|
||||||
@@ -1391,7 +1343,7 @@ export default function App() {
|
|||||||
if (selectedSearchForView) return getSearchTitle(selectedSearchForView);
|
if (selectedSearchForView) return getSearchTitle(selectedSearchForView);
|
||||||
if (selectedSearchSummary) return getSearchTitle(selectedSearchSummary);
|
if (selectedSearchSummary) return getSearchTitle(selectedSearchSummary);
|
||||||
return "New search";
|
return "New search";
|
||||||
}, [draftChatTitle, draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearchForView, selectedSearchSummary]);
|
}, [draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearchForView, selectedSearchSummary]);
|
||||||
|
|
||||||
const pageTitle = useMemo(() => {
|
const pageTitle = useMemo(() => {
|
||||||
if (draftKind || !selectedItem) return "Sybil";
|
if (draftKind || !selectedItem) return "Sybil";
|
||||||
@@ -1423,11 +1375,6 @@ export default function App() {
|
|||||||
setSelectedChat(null);
|
setSelectedChat(null);
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
setPendingAttachments([]);
|
setPendingAttachments([]);
|
||||||
setDraftChatTitle("");
|
|
||||||
setAdditionalSystemPrompt("");
|
|
||||||
setEnabledTools(getDefaultEnabledTools(availableChatTools));
|
|
||||||
setIsChatSettingsOpen(false);
|
|
||||||
setChatSettingsError(null);
|
|
||||||
setIsMobileSidebarOpen(false);
|
setIsMobileSidebarOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1445,8 +1392,6 @@ export default function App() {
|
|||||||
setSelectedChat(null);
|
setSelectedChat(null);
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
setPendingAttachments([]);
|
setPendingAttachments([]);
|
||||||
setIsChatSettingsOpen(false);
|
|
||||||
setChatSettingsError(null);
|
|
||||||
setIsMobileSidebarOpen(false);
|
setIsMobileSidebarOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1509,254 +1454,16 @@ export default function App() {
|
|||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [filteredSidebarItems, isAuthenticated, isQuickQuestionOpen]);
|
}, [filteredSidebarItems, isAuthenticated, isQuickQuestionOpen]);
|
||||||
|
|
||||||
const getRenameSeedTitle = (chatId: string) => {
|
|
||||||
if (selectedChat?.id === chatId) return getChatTitle(selectedChat, selectedChat.messages);
|
|
||||||
const summary = chats.find((chat) => chat.id === chatId);
|
|
||||||
if (summary) return getChatTitle(summary);
|
|
||||||
const sidebarItem = sidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
|
|
||||||
return sidebarItem?.title ?? "New chat";
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyChatSummary = (updatedChat: ChatSummary, moveToFront = true) => {
|
|
||||||
setChats((current) => {
|
|
||||||
const withoutExisting = current.filter((chat) => chat.id !== updatedChat.id);
|
|
||||||
if (moveToFront) return [updatedChat, ...withoutExisting];
|
|
||||||
const existingIndex = current.findIndex((chat) => chat.id === updatedChat.id);
|
|
||||||
if (existingIndex < 0) return [updatedChat, ...current];
|
|
||||||
const next = [...current];
|
|
||||||
next[existingIndex] = updatedChat;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(updatedChat), moveToFront));
|
|
||||||
setSelectedChat((current) => {
|
|
||||||
if (!current || current.id !== updatedChat.id) return current;
|
|
||||||
return {
|
|
||||||
...current,
|
|
||||||
title: updatedChat.title,
|
|
||||||
updatedAt: updatedChat.updatedAt,
|
|
||||||
starred: updatedChat.starred,
|
|
||||||
starredAt: updatedChat.starredAt,
|
|
||||||
initiatedProvider: updatedChat.initiatedProvider,
|
|
||||||
initiatedModel: updatedChat.initiatedModel,
|
|
||||||
lastUsedProvider: updatedChat.lastUsedProvider,
|
|
||||||
lastUsedModel: updatedChat.lastUsedModel,
|
|
||||||
additionalSystemPrompt: updatedChat.additionalSystemPrompt,
|
|
||||||
enabledTools: updatedChat.enabledTools,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const applySearchSummary = (updatedSearch: SearchSummary, moveToFront = true) => {
|
|
||||||
setSearches((current) => {
|
|
||||||
const withoutExisting = current.filter((search) => search.id !== updatedSearch.id);
|
|
||||||
if (moveToFront) return [updatedSearch, ...withoutExisting];
|
|
||||||
const existingIndex = current.findIndex((search) => search.id === updatedSearch.id);
|
|
||||||
if (existingIndex < 0) return [updatedSearch, ...current];
|
|
||||||
const next = [...current];
|
|
||||||
next[existingIndex] = updatedSearch;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setWorkspaceItems((current) => upsertWorkspaceItem(current, searchWorkspaceItem(updatedSearch), moveToFront));
|
|
||||||
setSelectedSearch((current) => {
|
|
||||||
if (!current || current.id !== updatedSearch.id) return current;
|
|
||||||
return {
|
|
||||||
...current,
|
|
||||||
title: updatedSearch.title,
|
|
||||||
query: updatedSearch.query,
|
|
||||||
updatedAt: updatedSearch.updatedAt,
|
|
||||||
starred: updatedSearch.starred,
|
|
||||||
starredAt: updatedSearch.starredAt,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const openRenameChatDialog = (chatId: string) => {
|
|
||||||
setContextMenu(null);
|
|
||||||
setRenameChatDraft(getRenameSeedTitle(chatId));
|
|
||||||
setRenameChatError(null);
|
|
||||||
setRenameChatDialog({ chatId });
|
|
||||||
};
|
|
||||||
|
|
||||||
const getChatSettingsSeedTitle = () => {
|
|
||||||
if (draftKind === "chat") return draftChatTitle;
|
|
||||||
if (selectedItem?.kind === "chat") {
|
|
||||||
if (selectedChat?.id === selectedItem.id) return getChatTitle(selectedChat, selectedChat.messages);
|
|
||||||
if (selectedChatSummary) return getChatTitle(selectedChatSummary);
|
|
||||||
}
|
|
||||||
return draftChatTitle;
|
|
||||||
};
|
|
||||||
|
|
||||||
const openChatSettings = () => {
|
|
||||||
if (isSearchMode) return;
|
|
||||||
setContextMenu(null);
|
|
||||||
setRenameChatDialog(null);
|
|
||||||
setChatSettingsError(null);
|
|
||||||
setChatSettingsTitleDraft(getChatSettingsSeedTitle());
|
|
||||||
setChatSettingsProviderDraft(provider);
|
|
||||||
setChatSettingsModelDraft(model);
|
|
||||||
setChatSettingsPromptDraft(additionalSystemPrompt);
|
|
||||||
setChatSettingsEnabledToolsDraft(normalizeEnabledTools(enabledTools, availableChatTools));
|
|
||||||
setIsChatSettingsOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleChatSettingsTool = (toolName: string) => {
|
|
||||||
setChatSettingsEnabledToolsDraft((current) => {
|
|
||||||
if (current.includes(toolName)) return current.filter((name) => name !== toolName);
|
|
||||||
return current.concat(toolName);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const commitLocalChatSettings = (nextProvider: Provider, nextModel: string, nextPrompt: string, nextTools: string[], nextTitle: string) => {
|
|
||||||
setProvider(nextProvider);
|
|
||||||
setModel(nextModel);
|
|
||||||
setProviderModelPreferences((current) => ({
|
|
||||||
...current,
|
|
||||||
[nextProvider]: nextModel || null,
|
|
||||||
}));
|
|
||||||
setAdditionalSystemPrompt(nextPrompt);
|
|
||||||
setEnabledTools(nextTools);
|
|
||||||
setDraftChatTitle(nextTitle);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChatSettingsSubmit = async (event?: Event) => {
|
|
||||||
event?.preventDefault();
|
|
||||||
if (isSavingChatSettings) return;
|
|
||||||
|
|
||||||
const nextModel = chatSettingsModelDraft.trim();
|
|
||||||
if (!nextModel) {
|
|
||||||
setChatSettingsError("Enter a model.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingChatId = draftKind === null && selectedItem?.kind === "chat" ? selectedItem.id : null;
|
|
||||||
const isExistingChat = existingChatId !== null;
|
|
||||||
const nextTitle = chatSettingsTitleDraft.trim();
|
|
||||||
if (isExistingChat && !nextTitle) {
|
|
||||||
setChatSettingsError("Enter a chat title.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextPrompt = chatSettingsPromptDraft.trim();
|
|
||||||
const nextTools = availableChatTools.length
|
|
||||||
? normalizeEnabledTools(chatSettingsEnabledToolsDraft, availableChatTools)
|
|
||||||
: chatSettingsEnabledToolsDraft;
|
|
||||||
|
|
||||||
setIsSavingChatSettings(true);
|
|
||||||
setChatSettingsError(null);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
if (isExistingChat) {
|
|
||||||
const updatedChat = await updateChatSettings(existingChatId, {
|
|
||||||
title: nextTitle,
|
|
||||||
additionalSystemPrompt: nextPrompt || null,
|
|
||||||
...(availableChatTools.length ? { enabledTools: nextTools } : {}),
|
|
||||||
});
|
|
||||||
applyChatSummary(updatedChat);
|
|
||||||
} else if (!selectedItem && draftKind !== "chat") {
|
|
||||||
setDraftKind("chat");
|
|
||||||
}
|
|
||||||
|
|
||||||
commitLocalChatSettings(chatSettingsProviderDraft, nextModel, nextPrompt, nextTools, nextTitle);
|
|
||||||
setIsChatSettingsOpen(false);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
if (message.includes("bearer token")) {
|
|
||||||
handleAuthFailure(message);
|
|
||||||
} else {
|
|
||||||
setChatSettingsError(message);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsSavingChatSettings(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
|
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const menuWidth = 176;
|
const menuWidth = 160;
|
||||||
const menuHeight = item.kind === "chat" ? 120 : 80;
|
const menuHeight = 40;
|
||||||
const padding = 8;
|
const padding = 8;
|
||||||
const x = Math.min(event.clientX, window.innerWidth - menuWidth - padding);
|
const x = Math.min(event.clientX, window.innerWidth - menuWidth - padding);
|
||||||
const y = Math.min(event.clientY, window.innerHeight - menuHeight - padding);
|
const y = Math.min(event.clientY, window.innerHeight - menuHeight - padding);
|
||||||
setContextMenu({ item, x: Math.max(padding, x), y: Math.max(padding, y) });
|
setContextMenu({ item, x: Math.max(padding, x), y: Math.max(padding, y) });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRenameChatSubmit = async (event?: Event) => {
|
|
||||||
event?.preventDefault();
|
|
||||||
if (!renameChatDialog || isRenamingChat) return;
|
|
||||||
|
|
||||||
const title = renameChatDraft.trim();
|
|
||||||
if (!title) {
|
|
||||||
setRenameChatError("Enter a chat title.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsRenamingChat(true);
|
|
||||||
setRenameChatError(null);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const updatedChat = await updateChatTitle(renameChatDialog.chatId, title);
|
|
||||||
applyChatSummary(updatedChat);
|
|
||||||
setRenameChatDialog(null);
|
|
||||||
setRenameChatDraft("");
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
if (message.includes("bearer token")) {
|
|
||||||
handleAuthFailure(message);
|
|
||||||
} else {
|
|
||||||
setRenameChatError(message);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsRenamingChat(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleStar = async (target: SidebarSelection) => {
|
|
||||||
const current = sidebarItems.find((item) => item.kind === target.kind && item.id === target.id);
|
|
||||||
const nextStarred = !current?.starred;
|
|
||||||
setContextMenu(null);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (target.kind === "chat") {
|
|
||||||
const updatedChat = await updateChatStar(target.id, nextStarred);
|
|
||||||
applyChatSummary(updatedChat, false);
|
|
||||||
} else {
|
|
||||||
const updatedSearch = await updateSearchStar(target.id, nextStarred);
|
|
||||||
applySearchSummary(updatedSearch, false);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
if (message.includes("bearer token")) {
|
|
||||||
handleAuthFailure(message);
|
|
||||||
} else {
|
|
||||||
setError(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleChatSettingsStar = async () => {
|
|
||||||
if (draftKind !== null || selectedItem?.kind !== "chat" || isTogglingChatSettingsStar) return;
|
|
||||||
const current = sidebarItems.find((item) => item.kind === "chat" && item.id === selectedItem.id);
|
|
||||||
const nextStarred = !current?.starred;
|
|
||||||
setIsTogglingChatSettingsStar(true);
|
|
||||||
setChatSettingsError(null);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updatedChat = await updateChatStar(selectedItem.id, nextStarred);
|
|
||||||
applyChatSummary(updatedChat, false);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
if (message.includes("bearer token")) {
|
|
||||||
handleAuthFailure(message);
|
|
||||||
} else {
|
|
||||||
setChatSettingsError(message);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsTogglingChatSettingsStar(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteFromContextMenu = async () => {
|
const handleDeleteFromContextMenu = async () => {
|
||||||
if (!contextMenu || isItemRunning(contextMenu.item)) return;
|
if (!contextMenu || isItemRunning(contextMenu.item)) return;
|
||||||
const target = contextMenu.item;
|
const target = contextMenu.item;
|
||||||
@@ -1796,26 +1503,6 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [contextMenu]);
|
}, [contextMenu]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!renameChatDialog) return;
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
renameChatInputRef.current?.focus();
|
|
||||||
renameChatInputRef.current?.select();
|
|
||||||
}, 0);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [renameChatDialog]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isChatSettingsOpen) return;
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key !== "Escape" || isSavingChatSettings) return;
|
|
||||||
event.preventDefault();
|
|
||||||
setIsChatSettingsOpen(false);
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [isChatSettingsOpen, isSavingChatSettings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isQuickQuestionOpen) return;
|
if (!isQuickQuestionOpen) return;
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -1976,17 +1663,9 @@ export default function App() {
|
|||||||
let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null;
|
let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null;
|
||||||
|
|
||||||
if (!chatId) {
|
if (!chatId) {
|
||||||
const initialEnabledTools = availableChatTools.length ? normalizeEnabledTools(enabledTools, availableChatTools) : undefined;
|
const chat = await createChat();
|
||||||
const chat = await createChat({
|
|
||||||
...(draftChatTitle.trim() ? { title: draftChatTitle.trim() } : {}),
|
|
||||||
provider,
|
|
||||||
model: selectedModel,
|
|
||||||
...(additionalSystemPrompt.trim() ? { additionalSystemPrompt: additionalSystemPrompt.trim() } : {}),
|
|
||||||
...(initialEnabledTools !== undefined ? { enabledTools: initialEnabledTools } : {}),
|
|
||||||
});
|
|
||||||
chatId = chat.id;
|
chatId = chat.id;
|
||||||
setDraftKind(null);
|
setDraftKind(null);
|
||||||
setDraftChatTitle("");
|
|
||||||
setChats((current) => {
|
setChats((current) => {
|
||||||
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
||||||
return [chat, ...withoutExisting];
|
return [chat, ...withoutExisting];
|
||||||
@@ -1998,14 +1677,10 @@ export default function App() {
|
|||||||
title: chat.title,
|
title: chat.title,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
starred: chat.starred,
|
|
||||||
starredAt: chat.starredAt,
|
|
||||||
initiatedProvider: chat.initiatedProvider,
|
initiatedProvider: chat.initiatedProvider,
|
||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
lastUsedModel: chat.lastUsedModel,
|
lastUsedModel: chat.lastUsedModel,
|
||||||
additionalSystemPrompt: chat.additionalSystemPrompt,
|
|
||||||
enabledTools: chat.enabledTools,
|
|
||||||
messages: [],
|
messages: [],
|
||||||
});
|
});
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
@@ -2222,8 +1897,6 @@ export default function App() {
|
|||||||
query,
|
query,
|
||||||
createdAt: currentSearch?.createdAt ?? nowIso,
|
createdAt: currentSearch?.createdAt ?? nowIso,
|
||||||
updatedAt: nowIso,
|
updatedAt: nowIso,
|
||||||
starred: currentSearch?.starred ?? false,
|
|
||||||
starredAt: currentSearch?.starredAt ?? null,
|
|
||||||
requestId: null,
|
requestId: null,
|
||||||
latencyMs: null,
|
latencyMs: null,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -2581,14 +2254,10 @@ export default function App() {
|
|||||||
title: chat.title,
|
title: chat.title,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
starred: chat.starred,
|
|
||||||
starredAt: chat.starredAt,
|
|
||||||
initiatedProvider: chat.initiatedProvider,
|
initiatedProvider: chat.initiatedProvider,
|
||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
lastUsedModel: chat.lastUsedModel,
|
lastUsedModel: chat.lastUsedModel,
|
||||||
additionalSystemPrompt: chat.additionalSystemPrompt,
|
|
||||||
enabledTools: chat.enabledTools,
|
|
||||||
messages: [],
|
messages: [],
|
||||||
});
|
});
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
@@ -2761,14 +2430,10 @@ export default function App() {
|
|||||||
title: chat.title,
|
title: chat.title,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
starred: chat.starred,
|
|
||||||
starredAt: chat.starredAt,
|
|
||||||
initiatedProvider: chat.initiatedProvider,
|
initiatedProvider: chat.initiatedProvider,
|
||||||
initiatedModel: chat.initiatedModel,
|
initiatedModel: chat.initiatedModel,
|
||||||
lastUsedProvider: chat.lastUsedProvider,
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
lastUsedModel: chat.lastUsedModel,
|
lastUsedModel: chat.lastUsedModel,
|
||||||
additionalSystemPrompt: chat.additionalSystemPrompt,
|
|
||||||
enabledTools: chat.enabledTools,
|
|
||||||
messages: [],
|
messages: [],
|
||||||
});
|
});
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
@@ -2837,10 +2502,6 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const chatSettingsChatId = draftKind === null && selectedItem?.kind === "chat" ? selectedItem.id : null;
|
|
||||||
const chatSettingsStarred = chatSettingsChatId
|
|
||||||
? sidebarItems.find((item) => item.kind === "chat" && item.id === chatSettingsChatId)?.starred ?? false
|
|
||||||
: false;
|
|
||||||
|
|
||||||
if (isCheckingSession) {
|
if (isCheckingSession) {
|
||||||
return (
|
return (
|
||||||
@@ -2863,7 +2524,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-grid-surface app-safe-frame h-full">
|
<div className="app-grid-surface h-full p-0 md:p-2">
|
||||||
<div className="flex h-full w-full overflow-hidden bg-transparent md:gap-2">
|
<div className="flex h-full w-full overflow-hidden bg-transparent md:gap-2">
|
||||||
{isMobileSidebarOpen ? (
|
{isMobileSidebarOpen ? (
|
||||||
<button
|
<button
|
||||||
@@ -2988,12 +2649,6 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||||
<span className="truncate text-sm font-semibold">{item.title}</span>
|
<span className="truncate text-sm font-semibold">{item.title}</span>
|
||||||
{item.starred ? (
|
|
||||||
<Star
|
|
||||||
className={cn("h-3.5 w-3.5 shrink-0 fill-amber-300", active ? "text-amber-200" : "text-amber-300/90")}
|
|
||||||
aria-label="Starred"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{itemIsRunning ? (
|
{itemIsRunning ? (
|
||||||
<LoaderCircle
|
<LoaderCircle
|
||||||
className={cn("h-3.5 w-3.5 shrink-0 animate-spin", active ? "text-cyan-100" : "text-cyan-300/90")}
|
className={cn("h-3.5 w-3.5 shrink-0 animate-spin", active ? "text-cyan-100" : "text-cyan-300/90")}
|
||||||
@@ -3019,8 +2674,8 @@ export default function App() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="glass-panel relative flex min-w-0 flex-1 flex-col overflow-hidden border-violet-300/18 md:rounded-2xl md:border">
|
<main className="glass-panel relative flex min-w-0 flex-1 flex-col overflow-hidden border-violet-300/18 md:rounded-2xl md:border">
|
||||||
<header className="flex items-center justify-between gap-2 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:gap-3 md:px-7">
|
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:px-7">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -3032,26 +2687,44 @@ export default function App() {
|
|||||||
<Menu className="h-4 w-4" />
|
<Menu className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
<div>
|
||||||
<h1 className="truncate text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
|
<h1 className="text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center justify-end gap-2">
|
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto">
|
||||||
{!isSearchMode ? (
|
{!isSearchMode ? (
|
||||||
<Button
|
<>
|
||||||
type="button"
|
<select
|
||||||
variant="secondary"
|
className="h-10 min-w-32 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-10 max-w-[44vw] gap-2 rounded-lg px-3 md:max-w-full"
|
value={provider}
|
||||||
onClick={openChatSettings}
|
onChange={(event) => {
|
||||||
|
const nextProvider = event.currentTarget.value as Provider;
|
||||||
|
setProvider(nextProvider);
|
||||||
|
const options = getModelOptions(modelCatalog, nextProvider);
|
||||||
|
setModel(pickProviderModel(options, providerModelPreferences[nextProvider]));
|
||||||
|
}}
|
||||||
disabled={isActiveSelectionSending}
|
disabled={isActiveSelectionSending}
|
||||||
aria-label="Open chat settings"
|
|
||||||
>
|
>
|
||||||
<Settings2 className="h-4 w-4 shrink-0" />
|
{providerOptions.map((candidate) => (
|
||||||
<span className="hidden shrink-0 sm:inline">Settings</span>
|
<option key={candidate} value={candidate}>
|
||||||
<span className="hidden min-w-0 max-w-[18rem] truncate text-xs font-medium text-violet-100/58 sm:inline">
|
{getProviderLabel(candidate)}
|
||||||
{getProviderLabel(provider)} · {model || "No model"}
|
</option>
|
||||||
</span>
|
))}
|
||||||
</Button>
|
</select>
|
||||||
|
<ModelCombobox
|
||||||
|
options={providerModelOptions}
|
||||||
|
value={model}
|
||||||
|
disabled={isActiveSelectionSending}
|
||||||
|
onChange={(nextModel) => {
|
||||||
|
const normalizedModel = nextModel.trim();
|
||||||
|
setModel(normalizedModel);
|
||||||
|
setProviderModelPreferences((current) => ({
|
||||||
|
...current,
|
||||||
|
[provider]: normalizedModel || null,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-10 items-center rounded-lg border border-cyan-300/22 bg-cyan-300/8 px-3 text-sm text-cyan-100">
|
<div className="flex h-10 items-center rounded-lg border border-cyan-300/22 bg-cyan-300/8 px-3 text-sm text-cyan-100">
|
||||||
<Globe2 className="mr-2 h-4 w-4" />
|
<Globe2 className="mr-2 h-4 w-4" />
|
||||||
@@ -3187,31 +2860,6 @@ export default function App() {
|
|||||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-violet-100 transition hover:bg-violet-400/12"
|
|
||||||
onClick={() => void handleToggleStar(contextMenu.item)}
|
|
||||||
>
|
|
||||||
<Star
|
|
||||||
className={cn(
|
|
||||||
"h-3.5 w-3.5",
|
|
||||||
sidebarItems.find((item) => item.kind === contextMenu.item.kind && item.id === contextMenu.item.id)?.starred
|
|
||||||
? "fill-amber-300 text-amber-300"
|
|
||||||
: ""
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{sidebarItems.find((item) => item.kind === contextMenu.item.kind && item.id === contextMenu.item.id)?.starred ? "Unstar" : "Star"}
|
|
||||||
</button>
|
|
||||||
{contextMenu.item.kind === "chat" ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-violet-100 transition hover:bg-violet-400/12"
|
|
||||||
onClick={() => openRenameChatDialog(contextMenu.item.id)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
Rename
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-rose-300 transition hover:bg-rose-500/12 disabled:text-muted-foreground"
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-rose-300 transition hover:bg-rose-500/12 disabled:text-muted-foreground"
|
||||||
@@ -3223,256 +2871,6 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isChatSettingsOpen ? (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
if (event.target === event.currentTarget && !isSavingChatSettings) setIsChatSettingsOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<form
|
|
||||||
role="dialog"
|
|
||||||
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)}
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h2 id="chat-settings-title" className="text-sm font-semibold text-violet-50">
|
|
||||||
Chat settings
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 truncate text-xs text-muted-foreground">{chatSettingsTitleDraft.trim() || "New chat"}</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={() => setIsChatSettingsOpen(false)}
|
|
||||||
disabled={isSavingChatSettings}
|
|
||||||
aria-label="Close chat settings"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
|
|
||||||
<div>
|
|
||||||
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Chat title</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
value={chatSettingsTitleDraft}
|
|
||||||
onInput={(event) => {
|
|
||||||
setChatSettingsTitleDraft(event.currentTarget.value);
|
|
||||||
if (chatSettingsError) setChatSettingsError(null);
|
|
||||||
}}
|
|
||||||
maxLength={120}
|
|
||||||
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"
|
|
||||||
disabled={isSavingChatSettings}
|
|
||||||
/>
|
|
||||||
{chatSettingsChatId ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="secondary"
|
|
||||||
className="h-11 w-11 shrink-0 rounded-lg"
|
|
||||||
onClick={() => void handleToggleChatSettingsStar()}
|
|
||||||
disabled={isSavingChatSettings || isTogglingChatSettingsStar}
|
|
||||||
aria-label={chatSettingsStarred ? "Unstar chat" : "Star chat"}
|
|
||||||
title={chatSettingsStarred ? "Unstar chat" : "Star chat"}
|
|
||||||
>
|
|
||||||
{isTogglingChatSettingsStar ? (
|
|
||||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Star className={cn("h-4 w-4", chatSettingsStarred ? "fill-amber-300 text-amber-300" : "")} />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-[minmax(9rem,0.7fr)_minmax(14rem,1fr)]">
|
|
||||||
<label className="block">
|
|
||||||
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Provider</span>
|
|
||||||
<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"
|
|
||||||
value={chatSettingsProviderDraft}
|
|
||||||
onChange={(event) => {
|
|
||||||
const nextProvider = event.currentTarget.value as Provider;
|
|
||||||
setChatSettingsProviderDraft(nextProvider);
|
|
||||||
const options = getModelOptions(modelCatalog, nextProvider);
|
|
||||||
setChatSettingsModelDraft(pickProviderModel(options, providerModelPreferences[nextProvider]));
|
|
||||||
setChatSettingsError(null);
|
|
||||||
}}
|
|
||||||
disabled={isSavingChatSettings}
|
|
||||||
>
|
|
||||||
{providerOptions.map((candidate) => (
|
|
||||||
<option key={candidate} value={candidate}>
|
|
||||||
{getProviderLabel(candidate)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="block min-w-0">
|
|
||||||
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Model</span>
|
|
||||||
<ModelCombobox
|
|
||||||
options={chatSettingsProviderModelOptions}
|
|
||||||
value={chatSettingsModelDraft}
|
|
||||||
disabled={isSavingChatSettings}
|
|
||||||
onChange={(nextModel) => {
|
|
||||||
setChatSettingsModelDraft(nextModel.trim());
|
|
||||||
setChatSettingsError(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="block">
|
|
||||||
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Additional system prompt</span>
|
|
||||||
<Textarea
|
|
||||||
rows={5}
|
|
||||||
value={chatSettingsPromptDraft}
|
|
||||||
onInput={(event) => {
|
|
||||||
setChatSettingsPromptDraft(event.currentTarget.value);
|
|
||||||
if (chatSettingsError) setChatSettingsError(null);
|
|
||||||
}}
|
|
||||||
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"
|
|
||||||
disabled={isSavingChatSettings}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
|
||||||
<h3 className="text-xs font-semibold text-violet-100/72">Tools</h3>
|
|
||||||
{availableChatTools.length ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setChatSettingsEnabledToolsDraft(getDefaultEnabledTools(availableChatTools))}
|
|
||||||
disabled={isSavingChatSettings}
|
|
||||||
>
|
|
||||||
<Check className="h-3.5 w-3.5" />
|
|
||||||
All
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setChatSettingsEnabledToolsDraft([])}
|
|
||||||
disabled={isSavingChatSettings}
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
None
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{availableChatTools.length ? (
|
|
||||||
availableChatTools.map((tool) => {
|
|
||||||
const checked = chatSettingsEnabledToolsDraft.includes(tool.name);
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => toggleChatSettingsTool(tool.name)}
|
|
||||||
className="mt-1 h-4 w-4 rounded border-violet-300/35 bg-background/80 accent-violet-400"
|
|
||||||
disabled={isSavingChatSettings}
|
|
||||||
/>
|
|
||||||
<span className="min-w-0">
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<p className="rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 text-sm text-muted-foreground">
|
|
||||||
No chat tools are available.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{chatSettingsError ? <p className="mt-3 text-sm text-rose-300">{chatSettingsError}</p> : null}
|
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setIsChatSettingsOpen(false)} disabled={isSavingChatSettings}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isSavingChatSettings}>
|
|
||||||
{isSavingChatSettings ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{renameChatDialog ? (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
if (event.target === event.currentTarget && !isRenamingChat) setRenameChatDialog(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<form
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="rename-chat-title"
|
|
||||||
className="glass-panel w-full max-w-md rounded-2xl border border-violet-300/24 p-4 shadow-2xl shadow-black/45 md:p-5"
|
|
||||||
onSubmit={(event) => void handleRenameChatSubmit(event)}
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
|
||||||
<h2 id="rename-chat-title" className="text-sm font-semibold text-violet-50">
|
|
||||||
Rename chat
|
|
||||||
</h2>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={() => setRenameChatDialog(null)}
|
|
||||||
disabled={isRenamingChat}
|
|
||||||
aria-label="Close rename dialog"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
ref={renameChatInputRef}
|
|
||||||
value={renameChatDraft}
|
|
||||||
onInput={(event) => {
|
|
||||||
setRenameChatDraft(event.currentTarget.value);
|
|
||||||
if (renameChatError) setRenameChatError(null);
|
|
||||||
}}
|
|
||||||
maxLength={120}
|
|
||||||
className="h-11 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)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
|
||||||
aria-label="Chat title"
|
|
||||||
disabled={isRenamingChat}
|
|
||||||
/>
|
|
||||||
{renameChatError ? <p className="mt-2 text-sm text-rose-300">{renameChatError}</p> : null}
|
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setRenameChatDialog(null)} disabled={isRenamingChat}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isRenamingChat || !renameChatDraft.trim()}>
|
|
||||||
{isRenamingChat ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
) : 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/72 p-3 backdrop-blur-md md:p-6"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type Props = {
|
|||||||
|
|
||||||
export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, authError, onSignIn }: Props) {
|
export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, authError, onSignIn }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="app-grid-surface app-safe-pad flex h-full items-center justify-center">
|
<div className="app-grid-surface flex h-full items-center justify-center p-4">
|
||||||
<div className="glass-panel w-full max-w-md rounded-2xl border border-violet-300/18 p-6">
|
<div className="glass-panel w-full max-w-md rounded-2xl border border-violet-300/18 p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<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">
|
||||||
|
|||||||
@@ -14,10 +14,6 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--safe-area-top: env(safe-area-inset-top, 0px);
|
|
||||||
--safe-area-right: env(safe-area-inset-right, 0px);
|
|
||||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
|
||||||
--safe-area-left: env(safe-area-inset-left, 0px);
|
|
||||||
--background: 235 45% 4%;
|
--background: 235 45% 4%;
|
||||||
--foreground: 258 36% 96%;
|
--foreground: 258 36% 96%;
|
||||||
--muted: 246 30% 13%;
|
--muted: 246 30% 13%;
|
||||||
@@ -44,15 +40,6 @@ html,
|
|||||||
body,
|
body,
|
||||||
#app {
|
#app {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@supports (height: 100dvh) {
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#app {
|
|
||||||
height: 100dvh;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -62,8 +49,6 @@ body {
|
|||||||
linear-gradient(90deg, hsl(187 92% 49% / 0.08), transparent 24%, hsl(264 92% 59% / 0.12) 74%, transparent),
|
linear-gradient(90deg, hsl(187 92% 49% / 0.08), transparent 24%, hsl(264 92% 59% / 0.12) 74%, transparent),
|
||||||
linear-gradient(180deg, hsl(250 60% 16% / 0.68), hsl(235 45% 4%) 48%, hsl(235 54% 3%));
|
linear-gradient(180deg, hsl(250 60% 16% / 0.68), hsl(235 45% 4%) 48%, hsl(235 54% 3%));
|
||||||
font-family: "Inter", "Avenir Next", "Segoe UI", sans-serif;
|
font-family: "Inter", "Avenir Next", "Segoe UI", sans-serif;
|
||||||
overflow: hidden;
|
|
||||||
overscroll-behavior: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
@@ -93,44 +78,6 @@ textarea {
|
|||||||
background-size: 48px 48px;
|
background-size: 48px 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-safe-frame {
|
|
||||||
padding: var(--safe-area-top) var(--safe-area-right) var(--safe-area-bottom) var(--safe-area-left);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-safe-pad {
|
|
||||||
padding:
|
|
||||||
max(1rem, var(--safe-area-top))
|
|
||||||
max(1rem, var(--safe-area-right))
|
|
||||||
max(1rem, var(--safe-area-bottom))
|
|
||||||
max(1rem, var(--safe-area-left));
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-search-safe-pad {
|
|
||||||
padding:
|
|
||||||
max(1.5rem, var(--safe-area-top))
|
|
||||||
max(0.75rem, var(--safe-area-right))
|
|
||||||
max(1.5rem, var(--safe-area-bottom))
|
|
||||||
max(0.75rem, var(--safe-area-left));
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.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));
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-search-safe-pad {
|
|
||||||
padding:
|
|
||||||
max(1.5rem, var(--safe-area-top))
|
|
||||||
max(1.5rem, var(--safe-area-right))
|
|
||||||
max(1.5rem, var(--safe-area-bottom))
|
|
||||||
max(1.5rem, var(--safe-area-left));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, hsl(243 42% 12% / 0.88), hsl(236 48% 5% / 0.92)),
|
linear-gradient(180deg, hsl(243 42% 12% / 0.88), hsl(236 48% 5% / 0.92)),
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ export type ChatSummary = {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
starred: boolean;
|
|
||||||
starredAt: string | null;
|
|
||||||
initiatedProvider: Provider | null;
|
initiatedProvider: Provider | null;
|
||||||
initiatedModel: string | null;
|
initiatedModel: string | null;
|
||||||
lastUsedProvider: Provider | null;
|
lastUsedProvider: Provider | null;
|
||||||
@@ -19,8 +17,6 @@ export type SearchSummary = {
|
|||||||
query: string | null;
|
query: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
starred: boolean;
|
|
||||||
starredAt: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChatWorkspaceItem = ChatSummary & {
|
export type ChatWorkspaceItem = ChatSummary & {
|
||||||
@@ -60,8 +56,6 @@ export type ChatDetail = {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
starred: boolean;
|
|
||||||
starredAt: string | null;
|
|
||||||
initiatedProvider: Provider | null;
|
initiatedProvider: Provider | null;
|
||||||
initiatedModel: string | null;
|
initiatedModel: string | null;
|
||||||
lastUsedProvider: Provider | null;
|
lastUsedProvider: Provider | null;
|
||||||
@@ -93,8 +87,6 @@ export type SearchDetail = {
|
|||||||
query: string | null;
|
query: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
starred: boolean;
|
|
||||||
starredAt: string | null;
|
|
||||||
requestId: string | null;
|
requestId: string | null;
|
||||||
latencyMs: number | null;
|
latencyMs: number | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -196,18 +188,6 @@ type CreateChatRequest = {
|
|||||||
messages?: CompletionRequestMessage[];
|
messages?: CompletionRequestMessage[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type CreateSearchRequest = {
|
|
||||||
title?: string;
|
|
||||||
query?: string;
|
|
||||||
reuseByQuery?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CreateSearchResponse = {
|
|
||||||
search: SearchSummary;
|
|
||||||
reused: boolean;
|
|
||||||
cacheHit: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||||
const ENV_ADMIN_TOKEN = (import.meta.env.VITE_ADMIN_TOKEN as string | undefined)?.trim() || null;
|
const ENV_ADMIN_TOKEN = (import.meta.env.VITE_ADMIN_TOKEN as string | undefined)?.trim() || null;
|
||||||
let authToken: string | null = ENV_ADMIN_TOKEN;
|
let authToken: string | null = ENV_ADMIN_TOKEN;
|
||||||
@@ -299,18 +279,7 @@ export async function updateChatTitle(chatId: string, title: string) {
|
|||||||
return data.chat;
|
return data.chat;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateChatStar(chatId: string, starred: boolean) {
|
export async function updateChatSettings(chatId: string, body: { additionalSystemPrompt?: string | null; enabledTools?: string[] }) {
|
||||||
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}/star`, {
|
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify({ starred }),
|
|
||||||
});
|
|
||||||
return data.chat;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateChatSettings(
|
|
||||||
chatId: string,
|
|
||||||
body: { title?: string; additionalSystemPrompt?: string | null; enabledTools?: string[] }
|
|
||||||
) {
|
|
||||||
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
|
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -335,35 +304,19 @@ export async function listSearches() {
|
|||||||
return data.searches;
|
return data.searches;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postSearch(body?: CreateSearchRequest) {
|
export async function createSearch(body?: { title?: string; query?: string }) {
|
||||||
return api<CreateSearchResponse>("/v1/searches", {
|
const data = await api<{ search: SearchSummary }>("/v1/searches", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body ?? {}),
|
body: JSON.stringify(body ?? {}),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
export async function createSearch(body?: CreateSearchRequest) {
|
|
||||||
const data = await postSearch(body);
|
|
||||||
return data.search;
|
return data.search;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createReusableSearch(body: Omit<CreateSearchRequest, "reuseByQuery">) {
|
|
||||||
return postSearch({ ...body, reuseByQuery: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSearch(searchId: string) {
|
export async function getSearch(searchId: string) {
|
||||||
const data = await api<{ search: SearchDetail }>(`/v1/searches/${searchId}`);
|
const data = await api<{ search: SearchDetail }>(`/v1/searches/${searchId}`);
|
||||||
return data.search;
|
return data.search;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSearchStar(searchId: string, starred: boolean) {
|
|
||||||
const data = await api<{ search: SearchSummary }>(`/v1/searches/${searchId}/star`, {
|
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify({ starred }),
|
|
||||||
});
|
|
||||||
return data.search;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createChatFromSearch(searchId: string, body?: { title?: string }) {
|
export async function createChatFromSearch(searchId: string, body?: { title?: string }) {
|
||||||
const data = await api<{ chat: ChatSummary }>(`/v1/searches/${searchId}/chat`, {
|
const data = await api<{ chat: ChatSummary }>(`/v1/searches/${searchId}/chat`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { render } from "preact";
|
import { render } from "preact";
|
||||||
import { RootRouter } from "@/root-router";
|
import { RootRouter } from "@/root-router";
|
||||||
import { registerServiceWorker } from "@/pwa";
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
registerServiceWorker();
|
|
||||||
|
|
||||||
render(<RootRouter />, document.getElementById("app")!);
|
render(<RootRouter />, document.getElementById("app")!);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { AuthScreen } from "@/components/auth/auth-screen";
|
|||||||
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { createReusableSearch, getSearch, runSearchStream, type SearchDetail } from "@/lib/api";
|
import { createSearch, runSearchStream, type SearchDetail } from "@/lib/api";
|
||||||
import { useSessionAuth } from "@/hooks/use-session-auth";
|
import { useSessionAuth } from "@/hooks/use-session-auth";
|
||||||
|
|
||||||
function readQueryFromUrl() {
|
function readQueryFromUrl() {
|
||||||
@@ -85,16 +85,14 @@ export default function SearchRoutePage() {
|
|||||||
|
|
||||||
const runQuery = async (query: string) => {
|
const runQuery = async (query: string) => {
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
const requestId = ++requestCounterRef.current;
|
|
||||||
streamAbortRef.current?.abort();
|
|
||||||
|
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
setSearch(null);
|
setSearch(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsRunning(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestId = ++requestCounterRef.current;
|
||||||
|
streamAbortRef.current?.abort();
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
streamAbortRef.current = abortController;
|
streamAbortRef.current = abortController;
|
||||||
let wasInterrupted = false;
|
let wasInterrupted = false;
|
||||||
@@ -108,8 +106,6 @@ export default function SearchRoutePage() {
|
|||||||
query: trimmed,
|
query: trimmed,
|
||||||
createdAt: nowIso,
|
createdAt: nowIso,
|
||||||
updatedAt: nowIso,
|
updatedAt: nowIso,
|
||||||
starred: false,
|
|
||||||
starredAt: null,
|
|
||||||
requestId: null,
|
requestId: null,
|
||||||
latencyMs: null,
|
latencyMs: null,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -121,11 +117,10 @@ export default function SearchRoutePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const createdResult = await createReusableSearch({
|
const created = await createSearch({
|
||||||
query: trimmed,
|
query: trimmed,
|
||||||
title: trimmed.slice(0, 80),
|
title: trimmed.slice(0, 80),
|
||||||
});
|
});
|
||||||
const created = createdResult.search;
|
|
||||||
if (requestId !== requestCounterRef.current) return;
|
if (requestId !== requestCounterRef.current) return;
|
||||||
|
|
||||||
setSearch((current) =>
|
setSearch((current) =>
|
||||||
@@ -137,19 +132,10 @@ export default function SearchRoutePage() {
|
|||||||
query: created.query,
|
query: created.query,
|
||||||
createdAt: created.createdAt,
|
createdAt: created.createdAt,
|
||||||
updatedAt: created.updatedAt,
|
updatedAt: created.updatedAt,
|
||||||
starred: created.starred,
|
|
||||||
starredAt: created.starredAt,
|
|
||||||
}
|
}
|
||||||
: current
|
: current
|
||||||
);
|
);
|
||||||
|
|
||||||
if (createdResult.cacheHit) {
|
|
||||||
const cached = await getSearch(created.id);
|
|
||||||
if (requestId !== requestCounterRef.current) return;
|
|
||||||
setSearch(cached);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await runSearchStream(
|
await runSearchStream(
|
||||||
created.id,
|
created.id,
|
||||||
{
|
{
|
||||||
@@ -262,7 +248,7 @@ export default function SearchRoutePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-search-safe-pad h-full overflow-y-auto">
|
<div className="h-full overflow-y-auto px-3 py-6 md:px-6">
|
||||||
<div className="mx-auto w-full max-w-4xl space-y-5">
|
<div className="mx-auto w-full max-w-4xl space-y-5">
|
||||||
<form
|
<form
|
||||||
className="flex items-center gap-2 rounded-xl border bg-background p-2 shadow-sm"
|
className="flex items-center gap-2 rounded-xl border bg-background p-2 shadow-sm"
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
export function registerServiceWorker() {
|
|
||||||
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
|
|
||||||
|
|
||||||
window.addEventListener("load", () => {
|
|
||||||
void navigator.serviceWorker.register("/sw.js").catch((error: unknown) => {
|
|
||||||
console.warn("Sybil service worker registration failed", error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
{"root":["./src/app.tsx","./src/main.tsx","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user