Compare commits
12 Commits
codex/syst
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 22aa652257 | |||
| 8f6e8c17a5 | |||
| fccc8110f4 | |||
| f71b69ca8b | |||
| dda20955bb | |||
|
|
4a2493c421 | ||
|
|
0bf0f95a67 | ||
| 600bc3befc | |||
| 5b7ed25522 | |||
| 39014eee18 | |||
| a6c2ec664b | |||
| cb8ea935fa |
5
dist/default.conf
vendored
5
dist/default.conf
vendored
@@ -17,6 +17,11 @@ 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,6 +42,23 @@ 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`
|
||||||
@@ -72,10 +89,14 @@ 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",
|
||||||
@@ -83,7 +104,9 @@ 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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -93,6 +116,7 @@ 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
|
||||||
|
|
||||||
@@ -106,6 +130,8 @@ 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",
|
||||||
@@ -121,13 +147,29 @@ 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: `{ "title": string }`
|
- Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": 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
|
||||||
@@ -140,7 +182,8 @@ 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.
|
||||||
- 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.
|
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
|
||||||
|
- 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 }`
|
||||||
@@ -219,6 +262,8 @@ Notes:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"additionalSystemPrompt": "optional one-off system prompt",
|
||||||
|
"enabledTools": ["web_search", "fetch_url"],
|
||||||
"temperature": 0.2,
|
"temperature": 0.2,
|
||||||
"maxTokens": 256
|
"maxTokens": 256
|
||||||
}
|
}
|
||||||
@@ -238,6 +283,8 @@ 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`.
|
||||||
@@ -267,7 +314,7 @@ Behavior notes:
|
|||||||
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64=<base64-private-key>` (optional fallback when a volume mount is not practical)
|
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64=<base64-private-key>` (optional fallback when a volume mount is not practical)
|
||||||
- `CHAT_CODEX_EXEC_TIMEOUT_MS=600000` (optional)
|
- `CHAT_CODEX_EXEC_TIMEOUT_MS=600000` (optional)
|
||||||
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional)
|
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional)
|
||||||
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests persist each completed tool call as its SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
|
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests emit an initiated SSE `tool_call` event before execution, then persist each completed or failed tool call as its terminal SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
|
||||||
- `anthropic` currently runs without server-managed tool calls.
|
- `anthropic` currently runs without server-managed tool calls.
|
||||||
|
|
||||||
## Searches
|
## Searches
|
||||||
@@ -276,8 +323,24 @@ Behavior notes:
|
|||||||
- Response: `{ "searches": SearchSummary[] }`
|
- Response: `{ "searches": SearchSummary[] }`
|
||||||
|
|
||||||
### `POST /v1/searches`
|
### `POST /v1/searches`
|
||||||
- Body: `{ "title"?: string, "query"?: string }`
|
- Body: `{ "title"?: string, "query"?: string, "reuseByQuery"?: boolean }`
|
||||||
|
- 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 }`
|
||||||
@@ -351,10 +414,14 @@ 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"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -399,17 +466,21 @@ 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": "..." }
|
{ "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "...", "starred": false, "starredAt": null }
|
||||||
```
|
```
|
||||||
|
|
||||||
`SearchDetail`
|
`SearchDetail`
|
||||||
@@ -420,6 +491,8 @@ Behavior notes:
|
|||||||
"query": "...",
|
"query": "...",
|
||||||
"createdAt": "...",
|
"createdAt": "...",
|
||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
|
"starred": false,
|
||||||
|
"starredAt": null,
|
||||||
"requestId": "...",
|
"requestId": "...",
|
||||||
"latencyMs": 123,
|
"latencyMs": 123,
|
||||||
"error": null,
|
"error": null,
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ Authentication:
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"additionalSystemPrompt": "optional one-off system prompt",
|
||||||
|
"enabledTools": ["web_search", "fetch_url"],
|
||||||
"temperature": 0.2,
|
"temperature": 0.2,
|
||||||
"maxTokens": 256
|
"maxTokens": 256
|
||||||
}
|
}
|
||||||
@@ -60,6 +62,8 @@ 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:
|
||||||
@@ -87,6 +91,8 @@ Event order:
|
|||||||
3. Zero or more `delta`
|
3. Zero or more `delta`
|
||||||
4. Exactly one terminal event: `done` or `error`
|
4. Exactly one terminal event: `done` or `error`
|
||||||
|
|
||||||
|
Each tool invocation can emit multiple `tool_call` events with the same `toolCallId`. The backend emits `status: "initiated"` before the tool starts executing, then emits `status: "completed"` or `status: "failed"` when execution finishes. Clients should upsert by `toolCallId` instead of appending each event.
|
||||||
|
|
||||||
### `meta`
|
### `meta`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -111,6 +117,19 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
|
|||||||
|
|
||||||
### `tool_call`
|
### `tool_call`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"toolCallId": "call_123",
|
||||||
|
"name": "web_search",
|
||||||
|
"status": "initiated",
|
||||||
|
"summary": "Searching web for 'latest CPI release'.",
|
||||||
|
"args": { "query": "latest CPI release" },
|
||||||
|
"startedAt": "2026-03-02T10:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Terminal tool-call event:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"toolCallId": "call_123",
|
"toolCallId": "call_123",
|
||||||
@@ -121,11 +140,12 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
|
|||||||
"startedAt": "2026-03-02T10:00:00.000Z",
|
"startedAt": "2026-03-02T10:00:00.000Z",
|
||||||
"completedAt": "2026-03-02T10:00:00.820Z",
|
"completedAt": "2026-03-02T10:00:00.820Z",
|
||||||
"durationMs": 820,
|
"durationMs": 820,
|
||||||
"error": null,
|
|
||||||
"resultPreview": "{\"ok\":true,...}"
|
"resultPreview": "{\"ok\":true,...}"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`status` is one of `initiated`, `completed`, or `failed`. `completedAt` and `durationMs` are only present on terminal events. `error` is present on failed terminal events; `resultPreview` is present on terminal events when available.
|
||||||
|
|
||||||
### `done`
|
### `done`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -174,7 +194,8 @@ Backend database remains source of truth.
|
|||||||
|
|
||||||
For persisted streams:
|
For persisted streams:
|
||||||
- Client may optimistically render accumulated `delta` text.
|
- Client may optimistically render accumulated `delta` text.
|
||||||
- Backend persists each completed tool call as a `tool` message before emitting its `tool_call` SSE event, so chat detail refreshes can show completed tool calls while the assistant response is still running.
|
- Backend emits initiated tool-call events without persisting them.
|
||||||
|
- Backend persists each completed or failed tool call as a `tool` message before emitting its terminal `tool_call` SSE event, so chat detail refreshes can show completed tool calls while the assistant response is still running.
|
||||||
|
|
||||||
On successful persisted completion:
|
On successful persisted completion:
|
||||||
- Backend persists assistant `Message` and updates `LlmCall` usage/latency in a transaction.
|
- Backend persists assistant `Message` and updates `LlmCall` usage/latency in a transaction.
|
||||||
|
|||||||
20
ios/.env.example
Normal file
20
ios/.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FASTLANE_APP_IDENTIFIER=net.buzzert.sybil2
|
||||||
|
FASTLANE_TEAM_ID=DQQH5H6GBD
|
||||||
|
FASTLANE_USER=you@example.com
|
||||||
|
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
|
||||||
|
FASTLANE_SKIP_UPDATE_CHECK=1
|
||||||
|
FASTLANE_HIDE_CHANGELOG=1
|
||||||
|
SYBIL_APP_STORE_APPLE_ID=6759442828
|
||||||
|
SYBIL_PROVIDER_PUBLIC_ID=c043d167-ad88-4036-84ea-76c223f1b1b2
|
||||||
|
|
||||||
|
# Optional App Store Connect API key settings for non-interactive upload and
|
||||||
|
# TestFlight build-number lookup.
|
||||||
|
APP_STORE_CONNECT_API_KEY_ID=
|
||||||
|
APP_STORE_CONNECT_API_ISSUER_ID=
|
||||||
|
APP_STORE_CONNECT_API_KEY_PATH=
|
||||||
|
APP_STORE_CONNECT_API_KEY_CONTENT=
|
||||||
|
APP_STORE_CONNECT_API_KEY_CONTENT_BASE64=false
|
||||||
|
|
||||||
|
# Optional deployment overrides.
|
||||||
|
SYBIL_BUILD_NUMBER=
|
||||||
|
SYBIL_VERSION_TAG=
|
||||||
11
ios/.gitignore
vendored
11
ios/.gitignore
vendored
@@ -1,2 +1,11 @@
|
|||||||
*.xcodeproj
|
*.xcodeproj
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
build/
|
||||||
|
*.ipa
|
||||||
|
*.dSYM.zip
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots/
|
||||||
|
fastlane/test_output/
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ targets:
|
|||||||
GENERATE_INFOPLIST_FILE: YES
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
INFOPLIST_FILE: Apps/Sybil/Info.plist
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
MARKETING_VERSION: 1.9
|
MARKETING_VERSION: "1.10"
|
||||||
CURRENT_PROJECT_VERSION: 10
|
CURRENT_PROJECT_VERSION: 11
|
||||||
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
INFOPLIST_KEY_CFBundleDisplayName: Sybil
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||||
|
|||||||
3
ios/Gemfile
Normal file
3
ios/Gemfile
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
gem "fastlane", "~> 2.227"
|
||||||
@@ -74,6 +74,26 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -118,6 +138,16 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -641,6 +671,14 @@ 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,12 +11,15 @@ 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
|
||||||
|
|||||||
@@ -7,38 +7,55 @@ struct SybilChatTranscriptView: View {
|
|||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
var topContentInset: CGFloat = 0
|
var topContentInset: CGFloat = 0
|
||||||
var bottomContentInset: CGFloat = 0
|
var bottomContentInset: CGFloat = 0
|
||||||
|
var bottomPinRequestID: Int = 0
|
||||||
|
|
||||||
private var hasPendingAssistant: Bool {
|
private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor"
|
||||||
messages.contains { message in
|
|
||||||
message.id.hasPrefix("temp-assistant-") && message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 26) {
|
LazyVStack(alignment: .leading, spacing: 26) {
|
||||||
ForEach(messages.reversed()) { message in
|
|
||||||
MessageBubble(message: message, isSending: isSending)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.scaleEffect(x: 1, y: -1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isLoading && messages.isEmpty {
|
if isLoading && messages.isEmpty {
|
||||||
Text("Loading messages…")
|
Text("Loading messages…")
|
||||||
.font(.sybil(.footnote))
|
.font(.sybil(.footnote))
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
.padding(.top, 24)
|
.padding(.top, 24)
|
||||||
.scaleEffect(x: 1, y: -1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ForEach(messages) { message in
|
||||||
|
MessageBubble(message: message, isSending: isSending)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
Color.clear
|
||||||
|
.frame(height: 18 + bottomContentInset)
|
||||||
|
.id(bottomAnchorID)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.top, 18 + bottomContentInset)
|
.padding(.top, 18 + topContentInset)
|
||||||
.padding(.bottom, 18 + topContentInset)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.scaleEffect(x: 1, y: -1)
|
.onAppear {
|
||||||
|
scrollToBottom(with: proxy, animated: false)
|
||||||
|
}
|
||||||
|
.onChange(of: bottomPinRequestID) { _, _ in
|
||||||
|
scrollToBottom(with: proxy, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool) {
|
||||||
|
let action = {
|
||||||
|
proxy.scrollTo(bottomAnchorID, anchor: .bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
if animated {
|
||||||
|
withAnimation(.easeOut(duration: 0.18), action)
|
||||||
|
} else {
|
||||||
|
action()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +155,12 @@ private struct MessageBubble: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct ToolCallActivityChip: View {
|
private struct ToolCallActivityChip: View {
|
||||||
|
enum VisualState {
|
||||||
|
case initiated
|
||||||
|
case completed
|
||||||
|
case failed
|
||||||
|
}
|
||||||
|
|
||||||
var metadata: ToolCallMetadata
|
var metadata: ToolCallMetadata
|
||||||
var fallbackContent: String
|
var fallbackContent: String
|
||||||
var createdAt: Date
|
var createdAt: Date
|
||||||
@@ -184,11 +207,22 @@ private struct ToolCallActivityChip: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var isFailed: Bool {
|
private var isFailed: Bool {
|
||||||
(metadata.status ?? "").lowercased() == "failed"
|
visualState == .failed
|
||||||
|
}
|
||||||
|
|
||||||
|
private var visualState: VisualState {
|
||||||
|
switch (metadata.status ?? "").lowercased() {
|
||||||
|
case "failed":
|
||||||
|
return .failed
|
||||||
|
case "initiated":
|
||||||
|
return .initiated
|
||||||
|
default:
|
||||||
|
return .completed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var detailLabel: String {
|
private var detailLabel: String {
|
||||||
var pieces: [String] = [isFailed ? "Failed" : "Completed"]
|
var pieces: [String] = [stateLabel]
|
||||||
if let durationMs = metadata.durationMs, durationMs > 0 {
|
if let durationMs = metadata.durationMs, durationMs > 0 {
|
||||||
pieces.append("\(durationMs) ms")
|
pieces.append("\(durationMs) ms")
|
||||||
}
|
}
|
||||||
@@ -200,14 +234,14 @@ private struct ToolCallActivityChip: View {
|
|||||||
HStack(alignment: .top, spacing: 11) {
|
HStack(alignment: .top, spacing: 11) {
|
||||||
ZStack {
|
ZStack {
|
||||||
RoundedRectangle(cornerRadius: 9)
|
RoundedRectangle(cornerRadius: 9)
|
||||||
.fill((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.13))
|
.fill(iconColor.opacity(0.13))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 9)
|
RoundedRectangle(cornerRadius: 9)
|
||||||
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
|
.stroke(iconColor.opacity(0.34), lineWidth: 1)
|
||||||
)
|
)
|
||||||
Image(systemName: iconName)
|
Image(systemName: iconName)
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.system(size: 14, weight: .semibold))
|
||||||
.foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.accent)
|
.foregroundStyle(iconColor)
|
||||||
}
|
}
|
||||||
.frame(width: 30, height: 30)
|
.frame(width: 30, height: 30)
|
||||||
|
|
||||||
@@ -221,7 +255,7 @@ private struct ToolCallActivityChip: View {
|
|||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text(toolLabel)
|
Text(toolLabel)
|
||||||
.font(.sybil(.caption2, weight: .semibold))
|
.font(.sybil(.caption2, weight: .semibold))
|
||||||
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.84) : SybilTheme.accent.opacity(0.90))
|
.foregroundStyle(iconColor.opacity(0.90))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
Text(detailLabel)
|
Text(detailLabel)
|
||||||
@@ -236,12 +270,45 @@ private struct ToolCallActivityChip: View {
|
|||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.fill(isFailed ? SybilTheme.failedToolCallGradient : SybilTheme.toolCallGradient)
|
.fill(backgroundGradient)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
|
.stroke(iconColor.opacity(0.34), lineWidth: 1)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.frame(maxWidth: 520, alignment: .leading)
|
.frame(maxWidth: 520, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var stateLabel: String {
|
||||||
|
switch visualState {
|
||||||
|
case .failed:
|
||||||
|
return "Failed"
|
||||||
|
case .initiated:
|
||||||
|
return "Running"
|
||||||
|
case .completed:
|
||||||
|
return "Completed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconColor: Color {
|
||||||
|
switch visualState {
|
||||||
|
case .failed:
|
||||||
|
return SybilTheme.danger
|
||||||
|
case .initiated:
|
||||||
|
return SybilTheme.warning
|
||||||
|
case .completed:
|
||||||
|
return SybilTheme.accent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backgroundGradient: LinearGradient {
|
||||||
|
switch visualState {
|
||||||
|
case .failed:
|
||||||
|
return SybilTheme.failedToolCallGradient
|
||||||
|
case .initiated:
|
||||||
|
return SybilTheme.runningToolCallGradient
|
||||||
|
case .completed:
|
||||||
|
return SybilTheme.toolCallGradient
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ 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?
|
||||||
@@ -166,6 +168,8 @@ 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 {
|
||||||
@@ -180,6 +184,8 @@ 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?
|
||||||
@@ -192,6 +198,8 @@ 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
|
||||||
@@ -205,6 +213,8 @@ 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
|
||||||
@@ -218,6 +228,8 @@ 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,
|
||||||
@@ -232,7 +244,9 @@ 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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,6 +391,8 @@ 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?
|
||||||
@@ -415,6 +431,8 @@ 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?
|
||||||
@@ -496,8 +514,8 @@ public struct CompletionStreamToolCall: Codable, Sendable {
|
|||||||
public var summary: String
|
public var summary: String
|
||||||
public var args: [String: JSONValue]
|
public var args: [String: JSONValue]
|
||||||
public var startedAt: String
|
public var startedAt: String
|
||||||
public var completedAt: String
|
public var completedAt: String?
|
||||||
public var durationMs: Int
|
public var durationMs: Int?
|
||||||
public var error: String?
|
public var error: String?
|
||||||
public var resultPreview: String?
|
public var resultPreview: String?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,8 +111,22 @@ 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()
|
||||||
@@ -146,6 +160,23 @@ 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)
|
||||||
@@ -163,6 +194,27 @@ 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 {
|
||||||
@@ -201,6 +253,12 @@ 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 {
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ enum SybilTheme {
|
|||||||
static let searchCard = Color(red: 0.07, green: 0.06, blue: 0.14)
|
static let searchCard = Color(red: 0.07, green: 0.06, blue: 0.14)
|
||||||
static let userBubble = Color(red: 0.29, green: 0.13, blue: 0.65)
|
static let userBubble = Color(red: 0.29, green: 0.13, blue: 0.65)
|
||||||
static let danger = Color(red: 0.96, green: 0.32, blue: 0.40)
|
static let danger = Color(red: 0.96, green: 0.32, blue: 0.40)
|
||||||
|
static let warning = Color(red: 0.95, green: 0.69, blue: 0.25)
|
||||||
|
|
||||||
@MainActor static func applySystemAppearance() {
|
@MainActor static func applySystemAppearance() {
|
||||||
let navAppearance = UINavigationBarAppearance()
|
let navAppearance = UINavigationBarAppearance()
|
||||||
@@ -186,6 +187,17 @@ enum SybilTheme {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static var runningToolCallGradient: LinearGradient {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 0.30, green: 0.19, blue: 0.04).opacity(0.72),
|
||||||
|
Color(red: 0.09, green: 0.05, blue: 0.17).opacity(0.78)
|
||||||
|
],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
static var failedToolCallGradient: LinearGradient {
|
static var failedToolCallGradient: LinearGradient {
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ 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
|
||||||
}
|
}
|
||||||
@@ -105,6 +107,7 @@ final class SybilViewModel {
|
|||||||
var isLoadingCollections = false
|
var isLoadingCollections = false
|
||||||
var isLoadingSelection = false
|
var isLoadingSelection = false
|
||||||
var isCreatingSearchChat = false
|
var isCreatingSearchChat = false
|
||||||
|
var chatBottomPinRequestID = 0
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
|
|
||||||
var composer = ""
|
var composer = ""
|
||||||
@@ -408,6 +411,8 @@ 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)
|
||||||
)
|
)
|
||||||
@@ -418,6 +423,8 @@ 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)
|
||||||
)
|
)
|
||||||
@@ -681,6 +688,8 @@ 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,
|
||||||
@@ -851,6 +860,57 @@ 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()
|
||||||
@@ -1127,7 +1187,7 @@ final class SybilViewModel {
|
|||||||
break
|
break
|
||||||
|
|
||||||
case let .toolCall(payload):
|
case let .toolCall(payload):
|
||||||
insertQuickQuestionToolCallMessage(payload)
|
upsertQuickQuestionToolCallMessage(payload)
|
||||||
|
|
||||||
case let .delta(payload):
|
case let .delta(payload):
|
||||||
guard !payload.text.isEmpty else { return }
|
guard !payload.text.isEmpty else { return }
|
||||||
@@ -1381,6 +1441,47 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -1599,6 +1700,10 @@ final class SybilViewModel {
|
|||||||
isLoadingSelection = false
|
isLoadingSelection = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func requestChatBottomPin() {
|
||||||
|
chatBottomPinRequestID += 1
|
||||||
|
}
|
||||||
|
|
||||||
private func startSelectionRefreshTask() -> Task<Void, Never> {
|
private func startSelectionRefreshTask() -> Task<Void, Never> {
|
||||||
isLoadingSelection = true
|
isLoadingSelection = true
|
||||||
let task = Task { [weak self] in
|
let task = Task { [weak self] in
|
||||||
@@ -1652,6 +1757,7 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
selectedChat = chat
|
selectedChat = chat
|
||||||
selectedSearch = nil
|
selectedSearch = nil
|
||||||
|
requestChatBottomPin()
|
||||||
|
|
||||||
if let provider = chat.lastUsedProvider,
|
if let provider = chat.lastUsedProvider,
|
||||||
let model = chat.lastUsedModel,
|
let model = chat.lastUsedModel,
|
||||||
@@ -1724,6 +1830,7 @@ final class SybilViewModel {
|
|||||||
} else {
|
} else {
|
||||||
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
pendingDraftChatState = PendingChatState(chatID: nil, messages: optimisticMessages)
|
||||||
}
|
}
|
||||||
|
requestChatBottomPin()
|
||||||
|
|
||||||
if chatID == nil {
|
if chatID == nil {
|
||||||
let created = try await client.createChat(title: nil)
|
let created = try await client.createChat(title: nil)
|
||||||
@@ -1745,6 +1852,8 @@ 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,
|
||||||
@@ -1769,6 +1878,7 @@ final class SybilViewModel {
|
|||||||
if let draftPending = pendingDraftChatState {
|
if let draftPending = pendingDraftChatState {
|
||||||
pendingDraftChatState = nil
|
pendingDraftChatState = nil
|
||||||
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: draftPending.messages)
|
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: draftPending.messages)
|
||||||
|
requestChatBottomPin()
|
||||||
} else if pendingChatStates[chatID] == nil {
|
} else if pendingChatStates[chatID] == nil {
|
||||||
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: optimisticMessages)
|
pendingChatStates[chatID] = PendingChatState(chatID: chatID, messages: optimisticMessages)
|
||||||
} else {
|
} else {
|
||||||
@@ -1805,18 +1915,7 @@ 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.chats = self.chats.map { existing in
|
self.applyChatSummary(updated, moveToFront: false)
|
||||||
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))")
|
||||||
@@ -1915,7 +2014,7 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case let .toolCall(payload):
|
case let .toolCall(payload):
|
||||||
insertPendingToolCallMessage(payload, chatID: chatID)
|
upsertPendingToolCallMessage(payload, chatID: chatID)
|
||||||
|
|
||||||
case let .delta(payload):
|
case let .delta(payload):
|
||||||
guard !payload.text.isEmpty else { return }
|
guard !payload.text.isEmpty else { return }
|
||||||
@@ -1975,6 +2074,8 @@ 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,
|
||||||
@@ -2129,12 +2230,14 @@ final class SybilViewModel {
|
|||||||
quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content)
|
quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
|
private func upsertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) {
|
||||||
guard var pending = pendingChatStates[chatID] else {
|
guard var pending = pendingChatStates[chatID] else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if pending.messages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
|
if let existingIndex = pending.messages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
|
||||||
|
pending.messages[existingIndex] = toolCallMessage(for: payload, id: pending.messages[existingIndex].id)
|
||||||
|
pendingChatStates[chatID] = pending
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2149,8 +2252,9 @@ final class SybilViewModel {
|
|||||||
pendingChatStates[chatID] = pending
|
pendingChatStates[chatID] = pending
|
||||||
}
|
}
|
||||||
|
|
||||||
private func insertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
|
private func upsertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
|
||||||
if quickQuestionMessages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) {
|
if let existingIndex = quickQuestionMessages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) {
|
||||||
|
quickQuestionMessages[existingIndex] = toolCallMessage(for: payload, id: quickQuestionMessages[existingIndex].id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2162,8 +2266,8 @@ final class SybilViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toolCallMessage(for payload: CompletionStreamToolCall) -> Message {
|
private func toolCallMessage(for payload: CompletionStreamToolCall, id: String? = nil) -> Message {
|
||||||
let metadata: JSONValue = .object([
|
var metadataObject: [String: JSONValue] = [
|
||||||
"kind": .string("tool_call"),
|
"kind": .string("tool_call"),
|
||||||
"toolCallId": .string(payload.toolCallId),
|
"toolCallId": .string(payload.toolCallId),
|
||||||
"toolName": .string(payload.name),
|
"toolName": .string(payload.name),
|
||||||
@@ -2171,19 +2275,26 @@ final class SybilViewModel {
|
|||||||
"summary": .string(payload.summary),
|
"summary": .string(payload.summary),
|
||||||
"args": .object(payload.args),
|
"args": .object(payload.args),
|
||||||
"startedAt": .string(payload.startedAt),
|
"startedAt": .string(payload.startedAt),
|
||||||
"completedAt": .string(payload.completedAt),
|
|
||||||
"durationMs": .number(Double(payload.durationMs)),
|
|
||||||
"error": payload.error.map { .string($0) } ?? .null,
|
"error": payload.error.map { .string($0) } ?? .null,
|
||||||
"resultPreview": payload.resultPreview.map { .string($0) } ?? .null
|
"resultPreview": payload.resultPreview.map { .string($0) } ?? .null
|
||||||
])
|
]
|
||||||
|
|
||||||
|
if let completedAt = payload.completedAt {
|
||||||
|
metadataObject["completedAt"] = .string(completedAt)
|
||||||
|
}
|
||||||
|
if let durationMs = payload.durationMs {
|
||||||
|
metadataObject["durationMs"] = .number(Double(durationMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata: JSONValue = .object(metadataObject)
|
||||||
|
|
||||||
let summary = payload.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
let summary = payload.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
? "Ran tool '\(payload.name)'."
|
? "Ran tool '\(payload.name)'."
|
||||||
: payload.summary
|
: payload.summary
|
||||||
|
|
||||||
return Message(
|
return Message(
|
||||||
id: "temp-tool-\(payload.toolCallId)",
|
id: id ?? "temp-tool-\(payload.toolCallId)",
|
||||||
createdAt: Date(),
|
createdAt: toolCallDate(from: payload.completedAt) ?? toolCallDate(from: payload.startedAt) ?? Date(),
|
||||||
role: .tool,
|
role: .tool,
|
||||||
content: summary,
|
content: summary,
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
@@ -2191,6 +2302,19 @@ final class SybilViewModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func toolCallDate(from value: String?) -> Date? {
|
||||||
|
guard let value else { return nil }
|
||||||
|
let fractionalFormatter = ISO8601DateFormatter()
|
||||||
|
fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
if let date = fractionalFormatter.date(from: value) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime]
|
||||||
|
return formatter.date(from: value)
|
||||||
|
}
|
||||||
|
|
||||||
private var currentChatID: String? {
|
private var currentChatID: String? {
|
||||||
if draftKind == .chat {
|
if draftKind == .chat {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -194,7 +194,8 @@ struct SybilWorkspaceView: View {
|
|||||||
isLoading: viewModel.isLoadingSelection,
|
isLoading: viewModel.isLoadingSelection,
|
||||||
isSending: viewModel.isSendingVisibleChat,
|
isSending: viewModel.isSendingVisibleChat,
|
||||||
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
|
||||||
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0
|
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
|
||||||
|
bottomPinRequestID: viewModel.chatBottomPinRequestID
|
||||||
)
|
)
|
||||||
.id(transcriptScrollContextID)
|
.id(transcriptScrollContextID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ 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
|
||||||
@@ -32,6 +35,9 @@ 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()
|
||||||
@@ -57,6 +63,9 @@ 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
|
||||||
) {
|
) {
|
||||||
@@ -66,6 +75,9 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +194,22 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -217,6 +245,14 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -459,6 +495,78 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
#expect(snapshot.listSearches == 0)
|
#expect(snapshot.listSearches == 0)
|
||||||
#expect(snapshot.getChat == 1)
|
#expect(snapshot.getChat == 1)
|
||||||
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
#expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
|
||||||
|
#expect(viewModel.chatBottomPinRequestID == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
@@ -575,6 +683,37 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
await sendTask.value
|
await sendTask.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func chatBottomPinRequestDoesNotFollowAssistantStreaming() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_245)
|
||||||
|
let chat = makeChatSummary(id: "chat-pin", date: date)
|
||||||
|
let detail = makeChatDetail(id: "chat-pin", date: date, body: "existing transcript")
|
||||||
|
let client = MockSybilClient(
|
||||||
|
chatsResponse: [chat],
|
||||||
|
chatDetails: ["chat-pin": detail]
|
||||||
|
)
|
||||||
|
await client.setCompletionStreamEvents([
|
||||||
|
.delta(CompletionStreamDelta(text: "partial ")),
|
||||||
|
.delta(CompletionStreamDelta(text: "response")),
|
||||||
|
.done(CompletionStreamDone(text: "partial response"))
|
||||||
|
])
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.chats = [chat]
|
||||||
|
viewModel.workspaceItems = [WorkspaceItem(chat: chat)]
|
||||||
|
viewModel.selectedItem = .chat("chat-pin")
|
||||||
|
viewModel.selectedChat = detail
|
||||||
|
viewModel.composer = "continue"
|
||||||
|
|
||||||
|
let initialPinRequestID = viewModel.chatBottomPinRequestID
|
||||||
|
await viewModel.sendComposer()
|
||||||
|
|
||||||
|
let snapshot = await client.currentSnapshot()
|
||||||
|
#expect(snapshot.runCompletionStream == 1)
|
||||||
|
#expect(viewModel.chatBottomPinRequestID == initialPinRequestID + 1)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
|
@Test func quickQuestionRunsNonPersistentCompletionStream() async throws {
|
||||||
let client = MockSybilClient()
|
let client = MockSybilClient()
|
||||||
|
|||||||
9
ios/fastlane/Appfile
Normal file
9
ios/fastlane/Appfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
require "dotenv"
|
||||||
|
|
||||||
|
Dotenv.load(File.expand_path("../.env", __dir__))
|
||||||
|
|
||||||
|
app_identifier(ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2"))
|
||||||
|
team_id(ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD"))
|
||||||
|
|
||||||
|
apple_id(ENV["FASTLANE_USER"]) if ENV["FASTLANE_USER"].to_s.strip.length.positive?
|
||||||
|
itc_team_id(ENV["FASTLANE_ITC_TEAM_ID"]) if ENV["FASTLANE_ITC_TEAM_ID"].to_s.strip.length.positive?
|
||||||
177
ios/fastlane/Fastfile
Normal file
177
ios/fastlane/Fastfile
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
require "dotenv"
|
||||||
|
require "open3"
|
||||||
|
require "shellwords"
|
||||||
|
require "yaml"
|
||||||
|
|
||||||
|
Dotenv.load(File.expand_path("../.env", __dir__))
|
||||||
|
|
||||||
|
default_platform(:ios)
|
||||||
|
|
||||||
|
APP_IDENTIFIER = ENV.fetch("FASTLANE_APP_IDENTIFIER", "net.buzzert.sybil2")
|
||||||
|
TEAM_ID = ENV.fetch("FASTLANE_TEAM_ID", "DQQH5H6GBD")
|
||||||
|
APP_STORE_APPLE_ID = ENV.fetch("SYBIL_APP_STORE_APPLE_ID", "6759442828")
|
||||||
|
PROVIDER_PUBLIC_ID = ENV.fetch("SYBIL_PROVIDER_PUBLIC_ID", "c043d167-ad88-4036-84ea-76c223f1b1b2")
|
||||||
|
IOS_ROOT = File.expand_path("..", __dir__)
|
||||||
|
PROJECT_FILE = File.join(IOS_ROOT, "Sybil.xcodeproj")
|
||||||
|
PROJECT_SPEC = File.join(IOS_ROOT, "project.yml")
|
||||||
|
APP_SPEC = File.join(IOS_ROOT, "Apps/Sybil/project.yml")
|
||||||
|
SCHEME = "Sybil"
|
||||||
|
TARGET = "SybilApp"
|
||||||
|
|
||||||
|
def present?(value)
|
||||||
|
!value.to_s.strip.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
def capture(command)
|
||||||
|
stdout, stderr, status = Open3.capture3(command)
|
||||||
|
return stdout.strip if status.success?
|
||||||
|
|
||||||
|
UI.user_error!("Command failed: #{command}\n#{stderr.strip}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def app_project_settings
|
||||||
|
YAML.safe_load(File.read(APP_SPEC)).fetch("targets").fetch(TARGET).fetch("settings").fetch("base")
|
||||||
|
end
|
||||||
|
|
||||||
|
def local_marketing_version
|
||||||
|
app_project_settings.fetch("MARKETING_VERSION").to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def local_build_number
|
||||||
|
app_project_settings.fetch("CURRENT_PROJECT_VERSION").to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_version_tag(tag)
|
||||||
|
version = tag.to_s.strip.sub(/\Av/, "")
|
||||||
|
unless version.match?(/\A\d+\.\d+(\.\d+)?\z/)
|
||||||
|
UI.user_error!("Release tag #{tag.inspect} must look like v1.10 or v1.10.0")
|
||||||
|
end
|
||||||
|
version
|
||||||
|
end
|
||||||
|
|
||||||
|
def release_version
|
||||||
|
tag = ENV["SYBIL_VERSION_TAG"]
|
||||||
|
tag = capture("git describe --tags --abbrev=0") unless present?(tag)
|
||||||
|
normalize_version_tag(tag)
|
||||||
|
end
|
||||||
|
|
||||||
|
def xcode_build_setting(key, value)
|
||||||
|
"#{key}=#{value.to_s.shellescape}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def app_store_connect_key_options
|
||||||
|
key_id = ENV["APP_STORE_CONNECT_API_KEY_ID"]
|
||||||
|
issuer_id = ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
|
||||||
|
return nil unless present?(key_id) && present?(issuer_id)
|
||||||
|
|
||||||
|
key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
|
||||||
|
key_content = ENV["APP_STORE_CONNECT_API_KEY_CONTENT"]
|
||||||
|
if present?(key_path)
|
||||||
|
{
|
||||||
|
key_id: key_id,
|
||||||
|
issuer_id: issuer_id,
|
||||||
|
key_filepath: key_path
|
||||||
|
}
|
||||||
|
elsif present?(key_content)
|
||||||
|
{
|
||||||
|
key_id: key_id,
|
||||||
|
issuer_id: issuer_id,
|
||||||
|
key_content: key_content,
|
||||||
|
is_key_content_base64: ENV["APP_STORE_CONNECT_API_KEY_CONTENT_BASE64"].to_s == "true"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
platform :ios do
|
||||||
|
desc "Show the version Fastlane will stamp into the next TestFlight archive"
|
||||||
|
lane :version do
|
||||||
|
UI.message("Git tag version: #{release_version}")
|
||||||
|
UI.message("Checked-in app version: #{local_marketing_version}")
|
||||||
|
UI.message("Checked-in build number: #{local_build_number}")
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Build Sybil and upload it to TestFlight"
|
||||||
|
lane :beta do
|
||||||
|
version = release_version
|
||||||
|
build_number = ENV["SYBIL_BUILD_NUMBER"].to_s
|
||||||
|
api_key = nil
|
||||||
|
|
||||||
|
if app_store_connect_key_options
|
||||||
|
api_key = app_store_connect_api_key(app_store_connect_key_options)
|
||||||
|
end
|
||||||
|
|
||||||
|
unless present?(build_number)
|
||||||
|
build_number = (local_build_number + 1).to_s
|
||||||
|
|
||||||
|
if api_key
|
||||||
|
begin
|
||||||
|
latest = latest_testflight_build_number(
|
||||||
|
app_identifier: APP_IDENTIFIER,
|
||||||
|
version: version,
|
||||||
|
api_key: api_key,
|
||||||
|
initial_build_number: local_build_number
|
||||||
|
).to_i
|
||||||
|
build_number = [latest + 1, local_build_number + 1].max.to_s
|
||||||
|
rescue StandardError => e
|
||||||
|
UI.important("Could not look up TestFlight build number: #{e.message}")
|
||||||
|
UI.important("Using checked-in build number + 1: #{build_number}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
UI.user_error!("Build number must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
|
||||||
|
|
||||||
|
sh("xcodegen --spec #{PROJECT_SPEC.shellescape}")
|
||||||
|
|
||||||
|
xcode_args = [
|
||||||
|
"-allowProvisioningUpdates",
|
||||||
|
xcode_build_setting("MARKETING_VERSION", version),
|
||||||
|
xcode_build_setting("CURRENT_PROJECT_VERSION", build_number)
|
||||||
|
].join(" ")
|
||||||
|
|
||||||
|
ipa_path = build_app(
|
||||||
|
project: PROJECT_FILE,
|
||||||
|
scheme: SCHEME,
|
||||||
|
clean: true,
|
||||||
|
sdk: "iphoneos",
|
||||||
|
export_method: "app-store",
|
||||||
|
output_directory: File.join(IOS_ROOT, "build/fastlane"),
|
||||||
|
output_name: "Sybil-#{version}-#{build_number}.ipa",
|
||||||
|
xcargs: xcode_args,
|
||||||
|
export_xcargs: "-allowProvisioningUpdates",
|
||||||
|
export_options: {
|
||||||
|
method: "app-store-connect",
|
||||||
|
destination: "export",
|
||||||
|
signingStyle: "automatic",
|
||||||
|
teamID: TEAM_ID,
|
||||||
|
manageAppVersionAndBuildNumber: false,
|
||||||
|
uploadSymbols: true,
|
||||||
|
stripSwiftSymbols: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipa_path ||= lane_context[SharedValues::IPA_OUTPUT_PATH]
|
||||||
|
UI.user_error!("IPA export failed; no IPA path was returned") unless present?(ipa_path) && File.exist?(ipa_path)
|
||||||
|
|
||||||
|
password = ENV["FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD"]
|
||||||
|
UI.user_error!("FASTLANE_USER is required for altool upload") unless present?(ENV["FASTLANE_USER"])
|
||||||
|
UI.user_error!("FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD is required for altool upload") unless present?(password)
|
||||||
|
UI.user_error!("SYBIL_APP_STORE_APPLE_ID is required for altool upload") unless present?(APP_STORE_APPLE_ID)
|
||||||
|
UI.user_error!("SYBIL_PROVIDER_PUBLIC_ID is required for altool upload") unless present?(PROVIDER_PUBLIC_ID)
|
||||||
|
|
||||||
|
ENV["ITMS_TRANSPORTER_PASSWORD"] = password
|
||||||
|
sh([
|
||||||
|
"xcrun altool",
|
||||||
|
"--upload-package #{ipa_path.shellescape}",
|
||||||
|
"--platform ios",
|
||||||
|
"--apple-id #{APP_STORE_APPLE_ID.shellescape}",
|
||||||
|
"--bundle-id #{APP_IDENTIFIER.shellescape}",
|
||||||
|
"--bundle-version #{build_number.shellescape}",
|
||||||
|
"--bundle-short-version-string #{version.shellescape}",
|
||||||
|
"--provider-public-id #{PROVIDER_PUBLIC_ID.shellescape}",
|
||||||
|
"--username #{ENV.fetch("FASTLANE_USER").shellescape}",
|
||||||
|
"--password @env:ITMS_TRANSPORTER_PASSWORD",
|
||||||
|
"--show-progress"
|
||||||
|
].join(" "))
|
||||||
|
end
|
||||||
|
end
|
||||||
40
ios/fastlane/README.md
Normal file
40
ios/fastlane/README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
fastlane documentation
|
||||||
|
----
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
Make sure you have the latest version of the Xcode command line tools installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
xcode-select --install
|
||||||
|
```
|
||||||
|
|
||||||
|
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||||
|
|
||||||
|
# Available Actions
|
||||||
|
|
||||||
|
## iOS
|
||||||
|
|
||||||
|
### ios version
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane ios version
|
||||||
|
```
|
||||||
|
|
||||||
|
Show the version Fastlane will stamp into the next TestFlight archive
|
||||||
|
|
||||||
|
### ios beta
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane ios beta
|
||||||
|
```
|
||||||
|
|
||||||
|
Build Sybil and upload it to TestFlight
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||||
|
|
||||||
|
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||||
|
|
||||||
|
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
||||||
12
ios/justfile
12
ios/justfile
@@ -5,8 +5,10 @@ derived_data := "build/DerivedData"
|
|||||||
default:
|
default:
|
||||||
@just build
|
@just build
|
||||||
|
|
||||||
build:
|
generate:
|
||||||
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
xcodegen --spec project.yml
|
||||||
|
|
||||||
|
build: generate
|
||||||
if command -v xcbeautify >/dev/null 2>&1; then \
|
if command -v xcbeautify >/dev/null 2>&1; then \
|
||||||
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
||||||
else \
|
else \
|
||||||
@@ -16,13 +18,15 @@ build:
|
|||||||
test:
|
test:
|
||||||
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
|
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
|
||||||
|
|
||||||
run:
|
run: generate
|
||||||
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
|
|
||||||
xcrun simctl boot '{{simulator_name}}' 2>/dev/null || true
|
xcrun simctl boot '{{simulator_name}}' 2>/dev/null || true
|
||||||
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}'
|
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}'
|
||||||
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
|
||||||
xcrun simctl launch booted net.buzzert.sybil2
|
xcrun simctl launch booted net.buzzert.sybil2
|
||||||
|
|
||||||
|
beta:
|
||||||
|
fastlane ios beta
|
||||||
|
|
||||||
screenshot path="build/sybil-screenshot.png":
|
screenshot path="build/sybil-screenshot.png":
|
||||||
mkdir -p "$(dirname '{{path}}')"
|
mkdir -p "$(dirname '{{path}}')"
|
||||||
xcrun simctl io booted screenshot '{{path}}'
|
xcrun simctl io booted screenshot '{{path}}'
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- 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");
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- 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,6 +27,11 @@ 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())
|
||||||
@@ -37,6 +42,7 @@ model User {
|
|||||||
|
|
||||||
chats Chat[]
|
chats Chat[]
|
||||||
searches Search[]
|
searches Search[]
|
||||||
|
projects Project[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Chat {
|
model Chat {
|
||||||
@@ -59,6 +65,7 @@ model Chat {
|
|||||||
|
|
||||||
messages Message[]
|
messages Message[]
|
||||||
calls LlmCall[]
|
calls LlmCall[]
|
||||||
|
projectItems ProjectItem[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
@@ -114,6 +121,7 @@ model Search {
|
|||||||
|
|
||||||
title String?
|
title String?
|
||||||
query String?
|
query String?
|
||||||
|
queryNormalized String?
|
||||||
|
|
||||||
source SearchSource @default(exa)
|
source SearchSource @default(exa)
|
||||||
|
|
||||||
@@ -132,8 +140,10 @@ model Search {
|
|||||||
userId String?
|
userId String?
|
||||||
|
|
||||||
results SearchResult[]
|
results SearchResult[]
|
||||||
|
projectItems ProjectItem[]
|
||||||
|
|
||||||
@@index([updatedAt])
|
@@index([updatedAt])
|
||||||
|
@@index([queryNormalized, updatedAt])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,3 +169,40 @@ 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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -292,15 +292,17 @@ type ToolAwareCompletionParams = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ToolExecutionStatus = "initiated" | "completed" | "failed";
|
||||||
|
|
||||||
export type ToolExecutionEvent = {
|
export type ToolExecutionEvent = {
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: "completed" | "failed";
|
status: ToolExecutionStatus;
|
||||||
summary: string;
|
summary: string;
|
||||||
args: Record<string, unknown>;
|
args: Record<string, unknown>;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
completedAt: string;
|
completedAt?: string;
|
||||||
durationMs: number;
|
durationMs?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
resultPreview?: string;
|
resultPreview?: string;
|
||||||
};
|
};
|
||||||
@@ -328,10 +330,13 @@ function toSingleLine(value: string, maxLength = 220) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildToolSummary(name: string, args: Record<string, unknown>, status: "completed" | "failed", error?: string) {
|
function buildToolSummary(name: string, args: Record<string, unknown>, status: ToolExecutionStatus, error?: string) {
|
||||||
const errSuffix = status === "failed" && error ? ` Error: ${toSingleLine(error, 140)}` : "";
|
const errSuffix = status === "failed" && error ? ` Error: ${toSingleLine(error, 140)}` : "";
|
||||||
if (name === "web_search") {
|
if (name === "web_search") {
|
||||||
const query = typeof args.query === "string" ? args.query.trim() : "";
|
const query = typeof args.query === "string" ? args.query.trim() : "";
|
||||||
|
if (status === "initiated") {
|
||||||
|
return query ? `Searching web for '${toSingleLine(query, 100)}'.` : "Searching web.";
|
||||||
|
}
|
||||||
if (status === "completed") {
|
if (status === "completed") {
|
||||||
return query ? `Performed web search for '${toSingleLine(query, 100)}'.` : "Performed web search.";
|
return query ? `Performed web search for '${toSingleLine(query, 100)}'.` : "Performed web search.";
|
||||||
}
|
}
|
||||||
@@ -340,6 +345,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
|
|||||||
|
|
||||||
if (name === "fetch_url") {
|
if (name === "fetch_url") {
|
||||||
const url = typeof args.url === "string" ? args.url.trim() : "";
|
const url = typeof args.url === "string" ? args.url.trim() : "";
|
||||||
|
if (status === "initiated") {
|
||||||
|
return url ? `Fetching URL ${toSingleLine(url, 140)}.` : "Fetching URL.";
|
||||||
|
}
|
||||||
if (status === "completed") {
|
if (status === "completed") {
|
||||||
return url ? `Fetched URL ${toSingleLine(url, 140)}.` : "Fetched URL.";
|
return url ? `Fetched URL ${toSingleLine(url, 140)}.` : "Fetched URL.";
|
||||||
}
|
}
|
||||||
@@ -348,6 +356,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
|
|||||||
|
|
||||||
if (name === "codex_exec") {
|
if (name === "codex_exec") {
|
||||||
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
|
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : "";
|
||||||
|
if (status === "initiated") {
|
||||||
|
return prompt ? `Running Codex task: '${toSingleLine(prompt, 120)}'.` : "Running Codex task.";
|
||||||
|
}
|
||||||
if (status === "completed") {
|
if (status === "completed") {
|
||||||
return prompt ? `Ran Codex task: '${toSingleLine(prompt, 120)}'.` : "Ran Codex task.";
|
return prompt ? `Ran Codex task: '${toSingleLine(prompt, 120)}'.` : "Ran Codex task.";
|
||||||
}
|
}
|
||||||
@@ -356,6 +367,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
|
|||||||
|
|
||||||
if (name === "shell_exec") {
|
if (name === "shell_exec") {
|
||||||
const command = typeof args.command === "string" ? args.command.trim() : "";
|
const command = typeof args.command === "string" ? args.command.trim() : "";
|
||||||
|
if (status === "initiated") {
|
||||||
|
return command ? `Running devbox shell command: '${toSingleLine(command, 120)}'.` : "Running devbox shell command.";
|
||||||
|
}
|
||||||
if (status === "completed") {
|
if (status === "completed") {
|
||||||
return command ? `Ran devbox shell command: '${toSingleLine(command, 120)}'.` : "Ran devbox shell command.";
|
return command ? `Ran devbox shell command: '${toSingleLine(command, 120)}'.` : "Ran devbox shell command.";
|
||||||
}
|
}
|
||||||
@@ -364,6 +378,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
|
|||||||
: `Devbox shell command failed.${errSuffix}`;
|
: `Devbox shell command failed.${errSuffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status === "initiated") {
|
||||||
|
return `Running tool '${name}'.`;
|
||||||
|
}
|
||||||
if (status === "completed") {
|
if (status === "completed") {
|
||||||
return `Ran tool '${name}'.`;
|
return `Ran tool '${name}'.`;
|
||||||
}
|
}
|
||||||
@@ -969,17 +986,55 @@ function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToo
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeToolCallAndBuildEvent(
|
type PreparedToolCallExecution = {
|
||||||
call: NormalizedToolCall,
|
startedAtMs: number;
|
||||||
params: ToolAwareCompletionParams
|
startedAt: string;
|
||||||
): Promise<{ event: ToolExecutionEvent; toolResult: ToolRunOutcome }> {
|
parsedArgs: Record<string, unknown>;
|
||||||
|
eventArgs: Record<string, unknown>;
|
||||||
|
parseError?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function prepareToolCallExecution(call: NormalizedToolCall): { event: ToolExecutionEvent; execution: PreparedToolCallExecution } {
|
||||||
const startedAtMs = Date.now();
|
const startedAtMs = Date.now();
|
||||||
const startedAt = new Date(startedAtMs).toISOString();
|
const startedAt = new Date(startedAtMs).toISOString();
|
||||||
let toolResult: ToolRunOutcome;
|
|
||||||
let parsedArgs: Record<string, unknown> = {};
|
let parsedArgs: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
let parseError: unknown;
|
||||||
try {
|
try {
|
||||||
parsedArgs = toRecord(parseToolArgs(call.arguments));
|
parsedArgs = toRecord(parseToolArgs(call.arguments));
|
||||||
toolResult = await executeTool(call.name, parsedArgs);
|
} catch (err) {
|
||||||
|
parseError = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventArgs = buildEventArgs(call.name, parsedArgs);
|
||||||
|
return {
|
||||||
|
event: {
|
||||||
|
toolCallId: call.id,
|
||||||
|
name: call.name,
|
||||||
|
status: "initiated",
|
||||||
|
summary: buildToolSummary(call.name, eventArgs, "initiated"),
|
||||||
|
args: eventArgs,
|
||||||
|
startedAt,
|
||||||
|
},
|
||||||
|
execution: {
|
||||||
|
startedAtMs,
|
||||||
|
startedAt,
|
||||||
|
parsedArgs,
|
||||||
|
eventArgs,
|
||||||
|
parseError,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeToolCallAndBuildEvent(
|
||||||
|
call: NormalizedToolCall,
|
||||||
|
execution: PreparedToolCallExecution,
|
||||||
|
params: ToolAwareCompletionParams
|
||||||
|
): Promise<{ event: ToolExecutionEvent; toolResult: ToolRunOutcome }> {
|
||||||
|
let toolResult: ToolRunOutcome;
|
||||||
|
try {
|
||||||
|
if (execution.parseError) throw execution.parseError;
|
||||||
|
toolResult = await executeTool(call.name, execution.parsedArgs);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toolResult = {
|
toolResult = {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -996,16 +1051,15 @@ async function executeToolCallAndBuildEvent(
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const completedAtMs = Date.now();
|
const completedAtMs = Date.now();
|
||||||
const eventArgs = buildEventArgs(call.name, parsedArgs);
|
|
||||||
const event: ToolExecutionEvent = {
|
const event: ToolExecutionEvent = {
|
||||||
toolCallId: call.id,
|
toolCallId: call.id,
|
||||||
name: call.name,
|
name: call.name,
|
||||||
status,
|
status,
|
||||||
summary: buildToolSummary(call.name, eventArgs, status, error),
|
summary: buildToolSummary(call.name, execution.eventArgs, status, error),
|
||||||
args: eventArgs,
|
args: execution.eventArgs,
|
||||||
startedAt,
|
startedAt: execution.startedAt,
|
||||||
completedAt: new Date(completedAtMs).toISOString(),
|
completedAt: new Date(completedAtMs).toISOString(),
|
||||||
durationMs: completedAtMs - startedAtMs,
|
durationMs: completedAtMs - execution.startedAtMs,
|
||||||
error,
|
error,
|
||||||
resultPreview: buildResultPreview(toolResult),
|
resultPreview: buildResultPreview(toolResult),
|
||||||
};
|
};
|
||||||
@@ -1068,7 +1122,8 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
|
|||||||
input.push(...outputItems);
|
input.push(...outputItems);
|
||||||
|
|
||||||
for (const call of normalizedToolCalls) {
|
for (const call of normalizedToolCalls) {
|
||||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
const { execution } = prepareToolCallExecution(call);
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||||
toolEvents.push(event);
|
toolEvents.push(event);
|
||||||
|
|
||||||
input.push({
|
input.push({
|
||||||
@@ -1155,7 +1210,8 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
|
|||||||
conversation.push(assistantToolCallMessage);
|
conversation.push(assistantToolCallMessage);
|
||||||
|
|
||||||
for (const call of normalizedToolCalls) {
|
for (const call of normalizedToolCalls) {
|
||||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
const { execution } = prepareToolCallExecution(call);
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||||
toolEvents.push(event);
|
toolEvents.push(event);
|
||||||
|
|
||||||
conversation.push({
|
conversation.push({
|
||||||
@@ -1299,7 +1355,9 @@ export async function* runToolAwareOpenAIChatStream(
|
|||||||
input.push(...responseOutputItems);
|
input.push(...responseOutputItems);
|
||||||
|
|
||||||
for (const call of normalizedToolCalls) {
|
for (const call of normalizedToolCalls) {
|
||||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||||
|
yield { type: "tool_call", event: initiatedEvent };
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||||
toolEvents.push(event);
|
toolEvents.push(event);
|
||||||
yield { type: "tool_call", event };
|
yield { type: "tool_call", event };
|
||||||
input.push({
|
input.push({
|
||||||
@@ -1436,7 +1494,9 @@ export async function* runToolAwareChatCompletionsStream(
|
|||||||
conversation.push(assistantToolCallMessage);
|
conversation.push(assistantToolCallMessage);
|
||||||
|
|
||||||
for (const call of normalizedToolCalls) {
|
for (const call of normalizedToolCalls) {
|
||||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, params);
|
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||||
|
yield { type: "tool_call", event: initiatedEvent };
|
||||||
|
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||||
toolEvents.push(event);
|
toolEvents.push(event);
|
||||||
yield { type: "tool_call", event };
|
yield { type: "tool_call", event };
|
||||||
conversation.push({
|
conversation.push({
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ev.type === "tool_call") {
|
if (ev.type === "tool_call") {
|
||||||
if (shouldPersist && chatId) {
|
if (ev.event.status !== "initiated" && shouldPersist && chatId) {
|
||||||
const toolMessage = buildToolLogMessageData(chatId, ev.event);
|
const toolMessage = buildToolLogMessageData(chatId, ev.event);
|
||||||
await prisma.message.create({
|
await prisma.message.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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"]);
|
||||||
@@ -399,21 +400,15 @@ 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";
|
||||||
|
|
||||||
function getErrorMessage(err: unknown) {
|
const starredProjectItemsSelect = {
|
||||||
return err instanceof Error ? err.message : String(err);
|
where: { projectId: STARRED_PROJECT_ID },
|
||||||
}
|
select: { createdAt: true },
|
||||||
|
take: 1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
function compareUpdatedAtDesc(a: { updatedAt: Date | string }, b: { updatedAt: Date | string }) {
|
const chatSummarySelect = {
|
||||||
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,
|
||||||
@@ -424,18 +419,131 @@ async function listWorkspaceItems() {
|
|||||||
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: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
|
select: searchSummarySelect,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...chats.map((chat) => ({ type: "chat" as const, ...serializeProviderFields(chat) })),
|
...chats.map((chat) => ({ type: "chat" as const, ...serializeChatLike(chat) })),
|
||||||
...searches.map((search) => ({ type: "search" as const, ...search })),
|
...searches.map((search) => ({ type: "search" as const, ...serializeSearchLike(search) })),
|
||||||
].sort(compareUpdatedAtDesc);
|
].sort(compareUpdatedAtDesc);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,6 +730,7 @@ 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,
|
||||||
@@ -642,12 +751,15 @@ 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: { results: { orderBy: { rank: "asc" } } },
|
include: {
|
||||||
|
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 } });
|
stream.complete({ event: "done", data: { search: serializeSearchLike(search) } });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = getErrorMessage(err);
|
const message = getErrorMessage(err);
|
||||||
@@ -656,6 +768,7 @@ 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,
|
||||||
@@ -706,20 +819,9 @@ 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: {
|
select: chatSummarySelect,
|
||||||
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) => serializeProviderFields(chat)) };
|
return { chats: chats.map((chat) => serializeChatLike(chat)) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/chats", async (req) => {
|
app.post("/v1/chats", async (req) => {
|
||||||
@@ -772,20 +874,9 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
select: {
|
select: chatSummarySelect,
|
||||||
id: true,
|
|
||||||
title: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
initiatedProvider: true,
|
|
||||||
initiatedModel: true,
|
|
||||||
lastUsedProvider: true,
|
|
||||||
lastUsedModel: true,
|
|
||||||
additionalSystemPrompt: true,
|
|
||||||
enabledTools: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return { chat: serializeProviderFields(chat) };
|
return { chat: serializeChatLike(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch("/v1/chats/:chatId", async (req) => {
|
app.patch("/v1/chats/:chatId", async (req) => {
|
||||||
@@ -811,23 +902,21 @@ 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 prisma.chat.findUnique({
|
const chat = await getChatSummary(chatId);
|
||||||
where: { id: chatId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
title: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
initiatedProvider: true,
|
|
||||||
initiatedModel: true,
|
|
||||||
lastUsedProvider: true,
|
|
||||||
lastUsedModel: true,
|
|
||||||
additionalSystemPrompt: true,
|
|
||||||
enabledTools: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!chat) return app.httpErrors.notFound("chat not found");
|
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||||
return { chat: serializeProviderFields(chat) };
|
return { chat };
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
return { chat };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/chats/title/suggest", async (req) => {
|
app.post("/v1/chats/title/suggest", async (req) => {
|
||||||
@@ -840,44 +929,24 @@ 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: {
|
select: chatSummarySelect,
|
||||||
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: serializeProviderFields(existing) };
|
if (existing.title?.trim()) return { chat: serializeChatLike(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);
|
||||||
|
|
||||||
const chat = await prisma.chat.update({
|
await prisma.chat.updateMany({
|
||||||
where: { id: body.chatId },
|
where: { id: body.chatId, title: existing.title },
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { chat: serializeProviderFields(chat) };
|
const chat = await getChatSummary(body.chatId);
|
||||||
|
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) => {
|
||||||
@@ -902,24 +971,69 @@ 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: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
|
select: searchSummarySelect,
|
||||||
});
|
});
|
||||||
return { searches };
|
return { searches: searches.map((search) => serializeSearchLike(search)) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/searches", async (req) => {
|
app.post("/v1/searches", async (req) => {
|
||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
const Body = z.object({ title: z.string().optional(), query: z.string().optional() });
|
const Body = z.object({
|
||||||
|
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: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
|
select: searchSummarySelect,
|
||||||
});
|
});
|
||||||
|
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 };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -946,10 +1060,13 @@ 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: { results: { orderBy: { rank: "asc" } } },
|
include: {
|
||||||
|
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 };
|
return { search: serializeSearchLike(search) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/searches/:searchId/chat", async (req) => {
|
app.post("/v1/searches/:searchId/chat", async (req) => {
|
||||||
@@ -985,21 +1102,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: {
|
select: chatSummarySelect,
|
||||||
id: true,
|
|
||||||
title: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
initiatedProvider: true,
|
|
||||||
initiatedModel: true,
|
|
||||||
lastUsedProvider: true,
|
|
||||||
lastUsedModel: true,
|
|
||||||
additionalSystemPrompt: true,
|
|
||||||
enabledTools: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { chat: serializeProviderFields(chat) };
|
return { chat: serializeChatLike(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/searches/:searchId/run", async (req) => {
|
app.post("/v1/searches/:searchId/run", async (req) => {
|
||||||
@@ -1060,6 +1166,7 @@ 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,
|
||||||
@@ -1084,10 +1191,13 @@ 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: { results: { orderBy: { rank: "asc" } } },
|
include: {
|
||||||
|
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 };
|
return { search: serializeSearchLike(search) };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
await prisma.search.update({
|
await prisma.search.update({
|
||||||
where: { id: searchId },
|
where: { id: searchId },
|
||||||
@@ -1142,10 +1252,14 @@ 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: { messages: { orderBy: { createdAt: "asc" } }, calls: { orderBy: { createdAt: "desc" } } },
|
include: {
|
||||||
|
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: serializeProviderFields(chat) };
|
return { chat: serializeChatLike(chat) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/chats/:chatId/messages", async (req) => {
|
app.post("/v1/chats/:chatId/messages", async (req) => {
|
||||||
|
|||||||
29
server/src/search-cache.ts
Normal file
29
server/src/search-cache.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -140,3 +140,69 @@ test("plain Chat Completions stream does not send Sybil-managed tools", async ()
|
|||||||
);
|
);
|
||||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("OpenAI-compatible Chat Completions stream emits initiated and terminal tool call updates", async () => {
|
||||||
|
let requestCount = 0;
|
||||||
|
const client = {
|
||||||
|
chat: {
|
||||||
|
completions: {
|
||||||
|
create: async () => {
|
||||||
|
requestCount += 1;
|
||||||
|
if (requestCount === 1) {
|
||||||
|
return streamFrom([
|
||||||
|
{
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
delta: {
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
id: "call_1",
|
||||||
|
function: {
|
||||||
|
name: "unknown_tool",
|
||||||
|
arguments: "{\"query\":\"current weather\"}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
finish_reason: "tool_calls",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return streamFrom([
|
||||||
|
{ choices: [{ delta: { content: "Done" } }] },
|
||||||
|
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = await collectEvents(
|
||||||
|
runToolAwareChatCompletionsStream({
|
||||||
|
client: client as any,
|
||||||
|
model: "grok-test",
|
||||||
|
messages: [{ role: "user", content: "Use a tool" }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
events.map((event) => event.type),
|
||||||
|
["tool_call", "tool_call", "delta", "done"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolEvents = events.flatMap((event) => (event.type === "tool_call" ? [event.event] : []));
|
||||||
|
assert.equal(toolEvents[0]?.toolCallId, "call_1");
|
||||||
|
assert.equal(toolEvents[0]?.status, "initiated");
|
||||||
|
assert.equal(toolEvents[0]?.completedAt, undefined);
|
||||||
|
assert.equal(toolEvents[0]?.durationMs, undefined);
|
||||||
|
assert.equal(toolEvents[1]?.toolCallId, "call_1");
|
||||||
|
assert.equal(toolEvents[1]?.status, "failed");
|
||||||
|
assert.match(toolEvents[1]?.error ?? "", /Unknown tool: unknown_tool/);
|
||||||
|
assert.equal(typeof toolEvents[1]?.completedAt, "string");
|
||||||
|
assert.equal(typeof toolEvents[1]?.durationMs, "number");
|
||||||
|
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
|
||||||
|
});
|
||||||
|
|||||||
25
server/tests/search-cache.test.ts
Normal file
25
server/tests/search-cache.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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,6 +60,22 @@ 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",
|
||||||
@@ -90,6 +106,14 @@ 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" });
|
||||||
}
|
}
|
||||||
|
|||||||
257
tui/src/index.ts
257
tui/src/index.ts
@@ -20,6 +20,8 @@ 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;
|
||||||
@@ -30,7 +32,7 @@ type ToolLogMetadata = {
|
|||||||
kind: "tool_call";
|
kind: "tool_call";
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
status?: "completed" | "failed";
|
status?: "initiated" | "completed" | "failed";
|
||||||
summary?: string;
|
summary?: string;
|
||||||
args?: Record<string, unknown>;
|
args?: Record<string, unknown>;
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
@@ -131,6 +133,8 @@ 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,
|
||||||
@@ -145,6 +149,8 @@ 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,
|
||||||
@@ -165,13 +171,7 @@ function isToolCallLogMessage(message: Message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildOptimisticToolMessage(event: ToolCallEvent): Message {
|
function buildOptimisticToolMessage(event: ToolCallEvent): Message {
|
||||||
return {
|
const metadata: ToolLogMetadata = {
|
||||||
id: `temp-tool-${event.toolCallId}`,
|
|
||||||
createdAt: event.completedAt ?? new Date().toISOString(),
|
|
||||||
role: "tool",
|
|
||||||
content: event.summary,
|
|
||||||
name: event.name,
|
|
||||||
metadata: {
|
|
||||||
kind: "tool_call",
|
kind: "tool_call",
|
||||||
toolCallId: event.toolCallId,
|
toolCallId: event.toolCallId,
|
||||||
toolName: event.name,
|
toolName: event.name,
|
||||||
@@ -179,12 +179,37 @@ function buildOptimisticToolMessage(event: ToolCallEvent): Message {
|
|||||||
summary: event.summary,
|
summary: event.summary,
|
||||||
args: event.args,
|
args: event.args,
|
||||||
startedAt: event.startedAt,
|
startedAt: event.startedAt,
|
||||||
completedAt: event.completedAt,
|
|
||||||
durationMs: event.durationMs,
|
|
||||||
error: event.error ?? null,
|
error: event.error ?? null,
|
||||||
resultPreview: event.resultPreview ?? null,
|
resultPreview: event.resultPreview ?? null,
|
||||||
} satisfies ToolLogMetadata,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (event.completedAt) metadata.completedAt = event.completedAt;
|
||||||
|
if (typeof event.durationMs === "number") metadata.durationMs = event.durationMs;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `temp-tool-${event.toolCallId}`,
|
||||||
|
createdAt: event.completedAt ?? event.startedAt ?? new Date().toISOString(),
|
||||||
|
role: "tool",
|
||||||
|
content: event.summary,
|
||||||
|
name: event.name,
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertOptimisticToolMessage(messages: Message[], event: ToolCallEvent) {
|
||||||
|
const toolMessage = buildOptimisticToolMessage(event);
|
||||||
|
const existingIndex = messages.findIndex(
|
||||||
|
(message) => asToolLogMetadata(message.metadata)?.toolCallId === event.toolCallId || message.id === `temp-tool-${event.toolCallId}`
|
||||||
|
);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
return messages.map((message, index) => (index === existingIndex ? { ...toolMessage, id: message.id } : message));
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantIndex = messages.findIndex(
|
||||||
|
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-")
|
||||||
|
);
|
||||||
|
if (assistantIndex < 0) return messages.concat(toolMessage);
|
||||||
|
return [...messages.slice(0, assistantIndex), toolMessage, ...messages.slice(assistantIndex)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) {
|
function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) {
|
||||||
@@ -254,6 +279,7 @@ 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,
|
||||||
@@ -361,6 +387,26 @@ 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() {
|
||||||
@@ -500,12 +546,13 @@ 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 `${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`;
|
return `${star}${kind} ${title} {gray-fg}${formatDate(item.updatedAt)}${escapeTags(initiatedLabel)}{/gray-fg}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const linesChanged =
|
const linesChanged =
|
||||||
@@ -574,7 +621,12 @@ async function main() {
|
|||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
const toolMeta = asToolLogMetadata(message.metadata);
|
const toolMeta = asToolLogMetadata(message.metadata);
|
||||||
if (message.role === "tool" && toolMeta) {
|
if (message.role === "tool" && toolMeta) {
|
||||||
const prefix = toolMeta.status === "failed" ? "{red-fg}[tool failed]{/red-fg}" : "{cyan-fg}[tool]{/cyan-fg}";
|
const prefix =
|
||||||
|
toolMeta.status === "failed"
|
||||||
|
? "{red-fg}[tool failed]{/red-fg}"
|
||||||
|
: toolMeta.status === "initiated"
|
||||||
|
? "{yellow-fg}[tool running]{/yellow-fg}"
|
||||||
|
: "{cyan-fg}[tool]{/cyan-fg}";
|
||||||
const summary = toolMeta.summary?.trim() || message.content.trim() || "Tool call executed.";
|
const summary = toolMeta.summary?.trim() || message.content.trim() || "Tool call executed.";
|
||||||
parts.push(`${prefix} ${escapeTags(summary)}`);
|
parts.push(`${prefix} ${escapeTags(summary)}`);
|
||||||
continue;
|
continue;
|
||||||
@@ -680,7 +732,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 [d] delete [q] quit";
|
"{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";
|
||||||
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}" : "";
|
||||||
@@ -842,6 +894,27 @@ 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);
|
||||||
@@ -910,10 +983,20 @@ 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 ? { ...chat, title: updated.title, updatedAt: updated.updatedAt } : chat));
|
chats = chats.map((chat) => (chat.id === updated.id ? updated : 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, title: updated.title, updatedAt: updated.updatedAt };
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
updateUI();
|
updateUI();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -964,6 +1047,8 @@ 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,
|
||||||
@@ -1022,29 +1107,7 @@ async function main() {
|
|||||||
},
|
},
|
||||||
onToolCall: (payload) => {
|
onToolCall: (payload) => {
|
||||||
if (!pendingChatState) return;
|
if (!pendingChatState) return;
|
||||||
const alreadyPresent = pendingChatState.messages.some(
|
pendingChatState = { ...pendingChatState, messages: upsertOptimisticToolMessage(pendingChatState.messages, payload) };
|
||||||
(message) =>
|
|
||||||
asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}`
|
|
||||||
);
|
|
||||||
if (alreadyPresent) return;
|
|
||||||
|
|
||||||
const toolMessage = buildOptimisticToolMessage(payload);
|
|
||||||
const assistantIndex = pendingChatState.messages.findIndex(
|
|
||||||
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-")
|
|
||||||
);
|
|
||||||
|
|
||||||
if (assistantIndex < 0) {
|
|
||||||
pendingChatState = { ...pendingChatState, messages: pendingChatState.messages.concat(toolMessage) };
|
|
||||||
} else {
|
|
||||||
pendingChatState = {
|
|
||||||
...pendingChatState,
|
|
||||||
messages: [
|
|
||||||
...pendingChatState.messages.slice(0, assistantIndex),
|
|
||||||
toolMessage,
|
|
||||||
...pendingChatState.messages.slice(assistantIndex),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
queueTranscriptScrollToBottomIfFollowing();
|
queueTranscriptScrollToBottomIfFollowing();
|
||||||
updateUI();
|
updateUI();
|
||||||
@@ -1140,6 +1203,8 @@ 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,
|
||||||
@@ -1302,6 +1367,88 @@ 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;
|
||||||
@@ -1387,18 +1534,18 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["q"], () => {
|
screen.key(["q"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
screen.destroy();
|
screen.destroy();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["tab"], () => {
|
screen.key(["tab"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
cycleFocus(1);
|
cycleFocus(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["S-tab", "backtab"], () => {
|
screen.key(["S-tab", "backtab"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
cycleFocus(-1);
|
cycleFocus(-1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1415,36 +1562,50 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["n"], () => {
|
screen.key(["n"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
handleCreateChat();
|
handleCreateChat();
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["/"], () => {
|
screen.key(["/"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
handleCreateSearch();
|
handleCreateSearch();
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["d"], () => {
|
screen.key(["d"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) 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 (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
if (getIsSearchMode() || isSending) return;
|
if (getIsSearchMode() || isSending) return;
|
||||||
cycleProvider();
|
cycleProvider();
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["m"], () => {
|
screen.key(["m"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) return;
|
||||||
if (getIsSearchMode() || isSending) return;
|
if (getIsSearchMode() || isSending) return;
|
||||||
cycleModel();
|
cycleModel();
|
||||||
});
|
});
|
||||||
|
|
||||||
screen.key(["r"], () => {
|
screen.key(["r"], () => {
|
||||||
if (isTextInputFocused(screen, composer)) return;
|
if (shouldIgnoreGlobalShortcut()) 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,6 +15,8 @@ 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;
|
||||||
@@ -27,6 +29,8 @@ 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 & {
|
||||||
@@ -51,12 +55,12 @@ export type Message = {
|
|||||||
export type ToolCallEvent = {
|
export type ToolCallEvent = {
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: "completed" | "failed";
|
status: "initiated" | "completed" | "failed";
|
||||||
summary: string;
|
summary: string;
|
||||||
args: Record<string, unknown>;
|
args: Record<string, unknown>;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
completedAt: string;
|
completedAt?: string;
|
||||||
durationMs: number;
|
durationMs?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
resultPreview?: string;
|
resultPreview?: string;
|
||||||
};
|
};
|
||||||
@@ -66,6 +70,8 @@ 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;
|
||||||
@@ -95,6 +101,8 @@ 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,12 +3,18 @@
|
|||||||
<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="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<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>
|
||||||
|
|||||||
BIN
web/public/icons/apple-touch-icon.png
Normal file
BIN
web/public/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
BIN
web/public/icons/favicon-32.png
Normal file
BIN
web/public/icons/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
web/public/icons/icon-192.png
Normal file
BIN
web/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
web/public/icons/icon-512.png
Normal file
BIN
web/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
BIN
web/public/icons/icon-maskable-512.png
Normal file
BIN
web/public/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
@@ -1,9 +1,32 @@
|
|||||||
{
|
{
|
||||||
|
"id": "/",
|
||||||
"name": "Sybil",
|
"name": "Sybil",
|
||||||
"short_name": "Sybil",
|
"short_name": "Sybil",
|
||||||
|
"description": "Sybil chat and search workspace",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "fullscreen",
|
||||||
"background_color": "#ffffff",
|
"display_override": ["fullscreen", "standalone"],
|
||||||
"theme_color": "#0f172a"
|
"background_color": "#0b0718",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
12
web/public/sw.js
Normal file
12
web/public/sw.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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));
|
||||||
|
});
|
||||||
807
web/src/App.tsx
807
web/src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -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 flex h-full items-center justify-center p-4">
|
<div className="app-grid-surface app-safe-pad flex h-full items-center justify-center">
|
||||||
<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,7 +14,7 @@ type ToolLogMetadata = {
|
|||||||
kind: "tool_call";
|
kind: "tool_call";
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
status?: "completed" | "failed";
|
status?: "initiated" | "completed" | "failed";
|
||||||
summary?: string;
|
summary?: string;
|
||||||
args?: Record<string, unknown>;
|
args?: Record<string, unknown>;
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
@@ -71,9 +71,17 @@ function formatToolTimestamp(...values: Array<string | null | undefined>) {
|
|||||||
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(new Date(value));
|
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, isFailed: boolean) {
|
type ToolCallVisualState = "initiated" | "completed" | "failed";
|
||||||
|
|
||||||
|
function getToolVisualState(metadata: ToolLogMetadata): ToolCallVisualState {
|
||||||
|
if (metadata.status === "failed") return "failed";
|
||||||
|
if (metadata.status === "initiated") return "initiated";
|
||||||
|
return "completed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, state: ToolCallVisualState) {
|
||||||
return [
|
return [
|
||||||
isFailed ? "Failed" : "Completed",
|
state === "failed" ? "Failed" : state === "initiated" ? "Running" : "Completed",
|
||||||
formatDuration(metadata.durationMs),
|
formatDuration(metadata.durationMs),
|
||||||
formatToolTimestamp(message.createdAt, metadata.completedAt, metadata.startedAt),
|
formatToolTimestamp(message.createdAt, metadata.completedAt, metadata.startedAt),
|
||||||
]
|
]
|
||||||
@@ -93,10 +101,12 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
|||||||
if (message.role === "tool" && toolLogMetadata) {
|
if (message.role === "tool" && toolLogMetadata) {
|
||||||
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
|
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
|
||||||
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
|
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench;
|
||||||
const isFailed = toolLogMetadata.status === "failed";
|
const toolState = getToolVisualState(toolLogMetadata);
|
||||||
|
const isFailed = toolState === "failed";
|
||||||
|
const isInitiated = toolState === "initiated";
|
||||||
const toolSummary = getToolSummary(message, toolLogMetadata);
|
const toolSummary = getToolSummary(message, toolLogMetadata);
|
||||||
const toolLabel = getToolLabel(message, toolLogMetadata);
|
const toolLabel = getToolLabel(message, toolLogMetadata);
|
||||||
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, isFailed);
|
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, toolState);
|
||||||
return (
|
return (
|
||||||
<div key={message.id} className="flex justify-start">
|
<div key={message.id} className="flex justify-start">
|
||||||
<div
|
<div
|
||||||
@@ -104,6 +114,8 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
|||||||
"inline-flex max-w-[85%] min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
|
"inline-flex max-w-[85%] min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
|
||||||
isFailed
|
isFailed
|
||||||
? "border-rose-400/34 bg-[linear-gradient(90deg,hsl(350_72%_44%_/_0.18),hsl(342_66%_9%_/_0.72))]"
|
? "border-rose-400/34 bg-[linear-gradient(90deg,hsl(350_72%_44%_/_0.18),hsl(342_66%_9%_/_0.72))]"
|
||||||
|
: isInitiated
|
||||||
|
? "border-amber-300/34 bg-[linear-gradient(90deg,hsl(43_74%_30%_/_0.34),hsl(260_48%_13%_/_0.74))]"
|
||||||
: "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]"
|
: "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]"
|
||||||
)}
|
)}
|
||||||
title={`${toolSummary}\n${toolLabel} • ${toolDetailLabel}`}
|
title={`${toolSummary}\n${toolLabel} • ${toolDetailLabel}`}
|
||||||
@@ -111,7 +123,11 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border",
|
"mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border",
|
||||||
isFailed ? "border-rose-400/34 bg-rose-400/13 text-rose-300" : "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
|
isFailed
|
||||||
|
? "border-rose-400/34 bg-rose-400/13 text-rose-300"
|
||||||
|
: isInitiated
|
||||||
|
? "border-amber-300/34 bg-amber-300/13 text-amber-200"
|
||||||
|
: "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
@@ -121,7 +137,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
|
|||||||
{toolSummary}
|
{toolSummary}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4">
|
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4">
|
||||||
<span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : "text-cyan-200/90")}>
|
<span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : isInitiated ? "text-amber-200/90" : "text-cyan-200/90")}>
|
||||||
{toolLabel}
|
{toolLabel}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span>
|
<span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span>
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
|
|
||||||
: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%;
|
||||||
@@ -40,6 +44,15 @@ html,
|
|||||||
body,
|
body,
|
||||||
#app {
|
#app {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (height: 100dvh) {
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -49,6 +62,8 @@ 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,
|
||||||
@@ -78,6 +93,44 @@ 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,6 +3,8 @@ 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;
|
||||||
@@ -17,6 +19,8 @@ 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 & {
|
||||||
@@ -41,12 +45,12 @@ export type Message = {
|
|||||||
export type ToolCallEvent = {
|
export type ToolCallEvent = {
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: "completed" | "failed";
|
status: "initiated" | "completed" | "failed";
|
||||||
summary: string;
|
summary: string;
|
||||||
args: Record<string, unknown>;
|
args: Record<string, unknown>;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
completedAt: string;
|
completedAt?: string;
|
||||||
durationMs: number;
|
durationMs?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
resultPreview?: string;
|
resultPreview?: string;
|
||||||
};
|
};
|
||||||
@@ -56,6 +60,8 @@ 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;
|
||||||
@@ -87,6 +93,8 @@ 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;
|
||||||
@@ -188,6 +196,18 @@ 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;
|
||||||
@@ -279,7 +299,18 @@ export async function updateChatTitle(chatId: string, title: string) {
|
|||||||
return data.chat;
|
return data.chat;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateChatSettings(chatId: string, body: { additionalSystemPrompt?: string | null; enabledTools?: string[] }) {
|
export async function updateChatStar(chatId: string, starred: boolean) {
|
||||||
|
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),
|
||||||
@@ -304,19 +335,35 @@ export async function listSearches() {
|
|||||||
return data.searches;
|
return data.searches;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSearch(body?: { title?: string; query?: string }) {
|
async function postSearch(body?: CreateSearchRequest) {
|
||||||
const data = await api<{ search: SearchSummary }>("/v1/searches", {
|
return api<CreateSearchResponse>("/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,5 +1,8 @@
|
|||||||
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 { createSearch, runSearchStream, type SearchDetail } from "@/lib/api";
|
import { createReusableSearch, getSearch, 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,14 +85,16 @@ 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;
|
||||||
@@ -106,6 +108,8 @@ 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,
|
||||||
@@ -117,10 +121,11 @@ export default function SearchRoutePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await createSearch({
|
const createdResult = await createReusableSearch({
|
||||||
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) =>
|
||||||
@@ -132,10 +137,19 @@ 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,
|
||||||
{
|
{
|
||||||
@@ -248,7 +262,7 @@ export default function SearchRoutePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-y-auto px-3 py-6 md:px-6">
|
<div className="app-search-safe-pad h-full overflow-y-auto">
|
||||||
<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"
|
||||||
|
|||||||
9
web/src/pwa.ts
Normal file
9
web/src/pwa.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
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/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/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"}
|
||||||
Reference in New Issue
Block a user