mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
Fix a few bugs
This commit is contained in:
+100
-25
@@ -30,6 +30,8 @@ import (
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
@@ -101,6 +103,7 @@ type Processor struct {
|
||||
workspaceCreator WorkspaceCreator
|
||||
modelResolver ModelResolver
|
||||
streamManager *StreamManager
|
||||
pubsub pubsub.Pubsub
|
||||
subagentService *SubagentService
|
||||
providerKeys ProviderAPIKeys
|
||||
providerKeysFn ProviderAPIKeysResolver
|
||||
@@ -323,6 +326,13 @@ func WithStreamManager(manager *StreamManager) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithPubsub sets the pubsub used to broadcast chat lifecycle events.
|
||||
func WithPubsub(ps pubsub.Pubsub) Option {
|
||||
return func(p *Processor) {
|
||||
p.pubsub = ps
|
||||
}
|
||||
}
|
||||
|
||||
// WithProviderAPIKeys sets fallback provider API keys used for model calls.
|
||||
func WithProviderAPIKeys(keys ProviderAPIKeys) Option {
|
||||
return func(p *Processor) {
|
||||
@@ -463,6 +473,65 @@ func (p *Processor) publishStatus(chatID uuid.UUID, status database.ChatStatus)
|
||||
})
|
||||
}
|
||||
|
||||
// publishChatPubsubEvent broadcasts a chat lifecycle event via PostgreSQL
|
||||
// pubsub so that all replicas can push updates to watching clients.
|
||||
func (p *Processor) publishChatPubsubEvent(chat database.Chat, kind coderdpubsub.ChatEventKind) {
|
||||
if p.pubsub == nil {
|
||||
return
|
||||
}
|
||||
event := coderdpubsub.ChatEvent{
|
||||
Kind: kind,
|
||||
Chat: convertChatForPubsub(chat),
|
||||
}
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
p.logger.Error(context.Background(), "failed to marshal chat pubsub event",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
if err := p.pubsub.Publish(coderdpubsub.ChatEventChannel(chat.OwnerID), payload); err != nil {
|
||||
p.logger.Error(context.Background(), "failed to publish chat pubsub event",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("kind", kind),
|
||||
slog.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// convertChatForPubsub converts a database.Chat to codersdk.Chat for pubsub events.
|
||||
// This is a lightweight conversion — we don't include diff status since
|
||||
// the watch subscriber can fetch that separately if needed.
|
||||
func convertChatForPubsub(c database.Chat) codersdk.Chat {
|
||||
chat := codersdk.Chat{
|
||||
ID: c.ID,
|
||||
OwnerID: c.OwnerID,
|
||||
Title: c.Title,
|
||||
Status: codersdk.ChatStatus(c.Status),
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
}
|
||||
if c.ParentChatID.Valid {
|
||||
parentChatID := c.ParentChatID.UUID
|
||||
chat.ParentChatID = &parentChatID
|
||||
}
|
||||
if c.RootChatID.Valid {
|
||||
rootChatID := c.RootChatID.UUID
|
||||
chat.RootChatID = &rootChatID
|
||||
} else if !c.ParentChatID.Valid {
|
||||
rootChatID := c.ID
|
||||
chat.RootChatID = &rootChatID
|
||||
}
|
||||
if c.WorkspaceID.Valid {
|
||||
chat.WorkspaceID = &c.WorkspaceID.UUID
|
||||
}
|
||||
if c.WorkspaceAgentID.Valid {
|
||||
chat.WorkspaceAgentID = &c.WorkspaceAgentID.UUID
|
||||
}
|
||||
return chat
|
||||
}
|
||||
|
||||
func (p *Processor) publishError(chatID uuid.UUID, message string) {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
@@ -688,6 +757,8 @@ func (p *Processor) processChat(ctx context.Context, chat database.Chat) {
|
||||
}
|
||||
|
||||
p.publishStatus(chat.ID, status)
|
||||
chat.Status = status
|
||||
p.publishChatPubsubEvent(chat, coderdpubsub.ChatEventKindStatusChange)
|
||||
if p.subagentService != nil {
|
||||
p.subagentService.publishChildStatus(chat, status)
|
||||
}
|
||||
@@ -767,9 +838,8 @@ func (p *Processor) runChat(
|
||||
}
|
||||
|
||||
type chatContextCompressionConfig struct {
|
||||
ContextLimit int64
|
||||
ThresholdPercent int32
|
||||
ThresholdExplicit bool
|
||||
ContextLimit int64
|
||||
ThresholdPercent int32
|
||||
}
|
||||
|
||||
type chatContextUsageSnapshot struct {
|
||||
@@ -864,17 +934,6 @@ func (p *Processor) resolveChatContextCompressionConfig(
|
||||
if chatConfig.ContextLimit > 0 {
|
||||
config.ContextLimit = chatConfig.ContextLimit
|
||||
}
|
||||
if chatConfig.ContextCompressionThreshold != nil {
|
||||
config.ThresholdPercent = normalizeContextCompressionThreshold(
|
||||
*chatConfig.ContextCompressionThreshold,
|
||||
)
|
||||
config.ThresholdExplicit = true
|
||||
}
|
||||
|
||||
if config.ContextLimit > 0 && config.ThresholdExplicit {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
modelName := strings.TrimSpace(chatConfig.Model)
|
||||
if modelName == "" {
|
||||
modelName = defaultChatModel
|
||||
@@ -903,11 +962,9 @@ func (p *Processor) resolveChatContextCompressionConfig(
|
||||
if config.ContextLimit <= 0 {
|
||||
config.ContextLimit = modelConfig.ContextLimit
|
||||
}
|
||||
if !config.ThresholdExplicit {
|
||||
config.ThresholdPercent = normalizeContextCompressionThreshold(
|
||||
modelConfig.CompressionThreshold,
|
||||
)
|
||||
}
|
||||
config.ThresholdPercent = normalizeContextCompressionThreshold(
|
||||
modelConfig.CompressionThreshold,
|
||||
)
|
||||
return config, nil
|
||||
}
|
||||
|
||||
@@ -1198,6 +1255,14 @@ func (p *Processor) runChatWithAgent(
|
||||
getWorkspaceConn,
|
||||
)
|
||||
|
||||
// Resolve the model config's context_limit so we can use it as a
|
||||
// fallback when the LLM provider doesn't include context_limit in
|
||||
// its response metadata (which is the common case).
|
||||
var modelConfigContextLimit int64
|
||||
if compressionConfig, err := p.resolveChatContextCompressionConfig(ctx, chat); err == nil {
|
||||
modelConfigContextLimit = compressionConfig.ContextLimit
|
||||
}
|
||||
|
||||
persistAssistant := func(
|
||||
content []fantasy.Content,
|
||||
usage fantasy.Usage,
|
||||
@@ -1375,10 +1440,18 @@ func (p *Processor) runChatWithAgent(
|
||||
toolResults := append([]ToolResultBlock(nil), stepToolResults...)
|
||||
stepStateMu.Unlock()
|
||||
|
||||
contextLimit := extractContextLimit(stepResult.ProviderMetadata)
|
||||
if !contextLimit.Valid && modelConfigContextLimit > 0 {
|
||||
contextLimit = sql.NullInt64{
|
||||
Int64: modelConfigContextLimit,
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
if err := persistAssistant(
|
||||
stepAssistantContent(stepResult.Content, toolResults),
|
||||
stepResult.Usage,
|
||||
extractContextLimit(stepResult.ProviderMetadata),
|
||||
contextLimit,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1695,7 +1768,10 @@ func (p *Processor) maybeGenerateChatTitle(
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
chat.Title = title
|
||||
p.publishChatPubsubEvent(chat, coderdpubsub.ChatEventKindTitleChange)
|
||||
}
|
||||
|
||||
func (p *Processor) generateChatTitle(ctx context.Context, input string) (string, error) {
|
||||
@@ -2820,11 +2896,10 @@ func intValue(value any) (int, bool) {
|
||||
}
|
||||
|
||||
type chatModelConfig struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Model string `json:"model"`
|
||||
WorkspaceMode codersdk.ChatWorkspaceMode `json:"workspace_mode,omitempty"`
|
||||
ContextLimit int64 `json:"context_limit,omitempty"`
|
||||
ContextCompressionThreshold *int32 `json:"context_compression_threshold,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Model string `json:"model"`
|
||||
WorkspaceMode codersdk.ChatWorkspaceMode `json:"workspace_mode,omitempty"`
|
||||
ContextLimit int64 `json:"context_limit,omitempty"`
|
||||
}
|
||||
|
||||
func workspaceModeFromChat(chat database.Chat) codersdk.ChatWorkspaceMode {
|
||||
|
||||
+142
-54
@@ -1,7 +1,6 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"charm.land/fantasy"
|
||||
"context"
|
||||
"database/sql"
|
||||
@@ -29,6 +28,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpapi/httperror"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/pubsub"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
@@ -118,6 +118,69 @@ type chatWorkspaceRequestMetadata struct {
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
}
|
||||
|
||||
// @Summary Watch chat list updates
|
||||
// @ID watch-chats
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Chats
|
||||
// @Success 200 {object} codersdk.ServerSentEvent
|
||||
// @Router /chats/watch [get]
|
||||
func (api *API) watchChats(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
apiKey := httpmw.APIKey(r)
|
||||
|
||||
sendEvent, senderClosed, err := httpapi.OneWayWebSocketEventSender(api.Logger)(rw, r)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to open chat watch stream.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
<-senderClosed
|
||||
}()
|
||||
|
||||
cancelSubscribe, err := api.Pubsub.SubscribeWithErr(pubsub.ChatEventChannel(apiKey.UserID),
|
||||
pubsub.HandleChatEvent(
|
||||
func(ctx context.Context, payload pubsub.ChatEvent, err error) {
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "chat event subscription error", slog.Error(err))
|
||||
return
|
||||
}
|
||||
_ = sendEvent(codersdk.ServerSentEvent{
|
||||
Type: codersdk.ServerSentEventTypeData,
|
||||
Data: payload,
|
||||
})
|
||||
},
|
||||
))
|
||||
if err != nil {
|
||||
_ = sendEvent(codersdk.ServerSentEvent{
|
||||
Type: codersdk.ServerSentEventTypeError,
|
||||
Data: codersdk.Response{
|
||||
Message: "Internal error subscribing to chat events.",
|
||||
Detail: err.Error(),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
defer cancelSubscribe()
|
||||
|
||||
// Send initial ping to signal the connection is ready.
|
||||
_ = sendEvent(codersdk.ServerSentEvent{
|
||||
Type: codersdk.ServerSentEventTypePing,
|
||||
})
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-senderClosed:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary List chats
|
||||
// @ID list-chats
|
||||
// @Security CoderSessionToken
|
||||
@@ -412,7 +475,6 @@ func (api *API) createChat(rw http.ResponseWriter, r *http.Request) {
|
||||
modelConfig, modelConfigErr = api.applyChatModelCompressionConfig(
|
||||
ctx,
|
||||
modelConfig,
|
||||
req.ContextCompressionThreshold,
|
||||
)
|
||||
if modelConfigErr != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
@@ -531,6 +593,8 @@ func (api *API) createChat(rw http.ResponseWriter, r *http.Request) {
|
||||
chat = updatedChat
|
||||
}
|
||||
|
||||
api.publishChatPubsubEvent(ctx, chat, pubsub.ChatEventKindCreated)
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusCreated, convertChat(chat, nil))
|
||||
}
|
||||
|
||||
@@ -636,7 +700,7 @@ func (api *API) deleteChat(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Check that the chat exists and user has access.
|
||||
_, err := api.Database.GetChatByID(ctx, chatID)
|
||||
chat, err := api.Database.GetChatByID(ctx, chatID)
|
||||
if err != nil {
|
||||
if httpapi.Is404Error(err) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
@@ -649,7 +713,43 @@ func (api *API) deleteChat(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
err = api.Database.DeleteChatByID(ctx, chatID)
|
||||
// Delete the chat and all its descendants in a transaction.
|
||||
// Child chats (sub-agent chats) reference their parent via
|
||||
// parent_chat_id with ON DELETE SET NULL, so without explicit
|
||||
// cleanup they would become orphaned root-level items.
|
||||
err = api.Database.InTx(func(tx database.Store) error {
|
||||
// Recursively collect all descendant chat IDs.
|
||||
var descendantIDs []uuid.UUID
|
||||
queue := []uuid.UUID{chatID}
|
||||
for len(queue) > 0 {
|
||||
parentID := queue[0]
|
||||
queue = queue[1:]
|
||||
children, err := tx.ListChildChatsByParentID(ctx, parentID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("list children of chat %s: %w", parentID, err)
|
||||
}
|
||||
for _, child := range children {
|
||||
descendantIDs = append(descendantIDs, child.ID)
|
||||
queue = append(queue, child.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete descendants first. The FK is ON DELETE SET NULL so
|
||||
// order doesn't strictly matter, but deleting children before
|
||||
// parents is cleaner.
|
||||
for i := len(descendantIDs) - 1; i >= 0; i-- {
|
||||
if err := tx.DeleteChatByID(ctx, descendantIDs[i]); err != nil {
|
||||
return xerrors.Errorf("delete descendant chat %s: %w", descendantIDs[i], err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the target chat itself.
|
||||
if err := tx.DeleteChatByID(ctx, chatID); err != nil {
|
||||
return xerrors.Errorf("delete chat: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, nil)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to delete chat.",
|
||||
@@ -658,6 +758,8 @@ func (api *API) deleteChat(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
api.publishChatPubsubEvent(ctx, chat, pubsub.ChatEventKindDeleted)
|
||||
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -706,39 +808,6 @@ func (api *API) createChatMessage(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Role == "user" && req.ContextCompressionThreshold != nil {
|
||||
updatedModelConfig, modelConfigErr := api.applyChatModelCompressionConfig(
|
||||
ctx,
|
||||
chat.ModelConfig,
|
||||
req.ContextCompressionThreshold,
|
||||
)
|
||||
if modelConfigErr != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid model configuration.",
|
||||
Detail: modelConfigErr.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !bytes.Equal(chat.ModelConfig, updatedModelConfig) {
|
||||
updatedChat, updateErr := api.Database.UpdateChatModelConfigByChatID(
|
||||
ctx,
|
||||
database.UpdateChatModelConfigByChatIDParams{
|
||||
ID: chat.ID,
|
||||
ModelConfig: updatedModelConfig,
|
||||
},
|
||||
)
|
||||
if updateErr != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to update chat model configuration.",
|
||||
Detail: updateErr.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
chat = updatedChat
|
||||
}
|
||||
}
|
||||
|
||||
// For user messages, atomically decide whether to queue or send based on
|
||||
// the chat status. We use a transaction with FOR UPDATE to prevent races
|
||||
// between the user sending and the processor finishing.
|
||||
@@ -877,6 +946,11 @@ func (api *API) createChatMessage(rw http.ResponseWriter, r *http.Request) {
|
||||
publishFn()
|
||||
}
|
||||
|
||||
if !response.Queued {
|
||||
chat.Status = database.ChatStatusPending
|
||||
api.publishChatPubsubEvent(ctx, chat, pubsub.ChatEventKindStatusChange)
|
||||
}
|
||||
|
||||
// Handle local workspace agent launch (outside the tx).
|
||||
if !response.Queued {
|
||||
if api.chatLocalService != nil {
|
||||
@@ -1324,6 +1398,8 @@ func (api *API) interruptChat(rw http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
api.publishChatPubsubEvent(ctx, chat, pubsub.ChatEventKindStatusChange)
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, convertChat(chat, nil))
|
||||
}
|
||||
|
||||
@@ -1733,6 +1809,32 @@ func (api *API) publishChatStatusEvent(chat database.Chat) {
|
||||
})
|
||||
}
|
||||
|
||||
// publishChatPubsubEvent broadcasts a chat lifecycle event via PostgreSQL
|
||||
// LISTEN/NOTIFY so that all server replicas can push real-time updates to
|
||||
// watching sidebar WebSocket connections.
|
||||
func (api *API) publishChatPubsubEvent(ctx context.Context, chat database.Chat, kind pubsub.ChatEventKind) {
|
||||
sdkChat := convertChat(chat, nil)
|
||||
event := pubsub.ChatEvent{
|
||||
Kind: kind,
|
||||
Chat: sdkChat,
|
||||
}
|
||||
payload, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "failed to marshal chat pubsub event",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
if err := api.Pubsub.Publish(pubsub.ChatEventChannel(chat.OwnerID), payload); err != nil {
|
||||
api.Logger.Error(ctx, "failed to publish chat pubsub event",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.F("kind", kind),
|
||||
slog.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (api *API) resolveChatDiffContents(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
@@ -2782,15 +2884,11 @@ func parseCanonicalChatModelRef(modelRef string) (string, string, bool) {
|
||||
func (api *API) applyChatModelCompressionConfig(
|
||||
ctx context.Context,
|
||||
modelConfigRaw json.RawMessage,
|
||||
requestedThreshold *int32,
|
||||
) (json.RawMessage, error) {
|
||||
config := map[string]any{}
|
||||
if len(modelConfigRaw) > 0 {
|
||||
if err := json.Unmarshal(modelConfigRaw, &config); err != nil {
|
||||
if requestedThreshold == nil {
|
||||
return modelConfigRaw, nil
|
||||
}
|
||||
return nil, xerrors.Errorf("parse model config: %w", err)
|
||||
return modelConfigRaw, nil
|
||||
}
|
||||
}
|
||||
if config == nil {
|
||||
@@ -2816,20 +2914,10 @@ func (api *API) applyChatModelCompressionConfig(
|
||||
}
|
||||
}
|
||||
|
||||
thresholdFallback := defaultChatContextCompressionThreshold
|
||||
if existingThreshold, ok := chatCompressionThresholdFromModelConfig(config); ok {
|
||||
thresholdFallback = existingThreshold
|
||||
if _, ok := chatCompressionThresholdFromModelConfig(config); !ok {
|
||||
config[chatContextCompressionThresholdModelConfigKey] = defaultChatContextCompressionThreshold
|
||||
}
|
||||
|
||||
threshold, err := normalizeChatCompressionThreshold(
|
||||
requestedThreshold,
|
||||
thresholdFallback,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config[chatContextCompressionThresholdModelConfigKey] = threshold
|
||||
|
||||
encoded, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("encode model config: %w", err)
|
||||
|
||||
@@ -821,6 +821,7 @@ func New(options *Options) *API {
|
||||
chatd.WithAgentConnector(api.agentProvider),
|
||||
chatd.WithWorkspaceCreator(api.newChatWorkspaceCreator()),
|
||||
chatd.WithStreamManager(api.chatStreamManager),
|
||||
chatd.WithPubsub(options.Pubsub),
|
||||
)
|
||||
}
|
||||
api.chatProcessor = options.ChatProcessor
|
||||
@@ -1235,6 +1236,7 @@ func New(options *Options) *API {
|
||||
r.Get("/", api.listChats)
|
||||
r.Post("/", api.createChat)
|
||||
r.Get("/models", api.listChatModels)
|
||||
r.Get("/watch", api.watchChats)
|
||||
r.Route("/providers", func(r chi.Router) {
|
||||
r.Get("/", api.listChatProviders)
|
||||
r.Post("/", api.createChatProvider)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package pubsub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func ChatEventChannel(ownerID uuid.UUID) string {
|
||||
return fmt.Sprintf("chat:owner:%s", ownerID)
|
||||
}
|
||||
|
||||
func HandleChatEvent(cb func(ctx context.Context, payload ChatEvent, err error)) func(ctx context.Context, message []byte, err error) {
|
||||
return func(ctx context.Context, message []byte, err error) {
|
||||
if err != nil {
|
||||
cb(ctx, ChatEvent{}, xerrors.Errorf("chat event pubsub: %w", err))
|
||||
return
|
||||
}
|
||||
var payload ChatEvent
|
||||
if err := json.Unmarshal(message, &payload); err != nil {
|
||||
cb(ctx, ChatEvent{}, xerrors.Errorf("unmarshal chat event"))
|
||||
return
|
||||
}
|
||||
|
||||
cb(ctx, payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
type ChatEvent struct {
|
||||
Kind ChatEventKind `json:"kind"`
|
||||
Chat codersdk.Chat `json:"chat"`
|
||||
}
|
||||
|
||||
type ChatEventKind string
|
||||
|
||||
const (
|
||||
ChatEventKindStatusChange ChatEventKind = "status_change"
|
||||
ChatEventKindTitleChange ChatEventKind = "title_change"
|
||||
ChatEventKindCreated ChatEventKind = "created"
|
||||
ChatEventKindDeleted ChatEventKind = "deleted"
|
||||
)
|
||||
@@ -143,7 +143,6 @@ type CreateChatRequest struct {
|
||||
ParentChatID *uuid.UUID `json:"parent_chat_id,omitempty" format:"uuid"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ModelConfig json.RawMessage `json:"model_config,omitempty"`
|
||||
ContextCompressionThreshold *int32 `json:"context_compression_threshold,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateChatRequest is the request to update a chat.
|
||||
@@ -157,7 +156,6 @@ type CreateChatMessageRequest struct {
|
||||
Content json.RawMessage `json:"content,omitempty"`
|
||||
ToolCallID *string `json:"tool_call_id,omitempty"`
|
||||
Thinking *string `json:"thinking,omitempty"`
|
||||
ContextCompressionThreshold *int32 `json:"context_compression_threshold,omitempty"`
|
||||
}
|
||||
|
||||
// CreateChatMessageResponse is the response from adding a message to a chat.
|
||||
|
||||
@@ -146,6 +146,13 @@ export const watchChat = (
|
||||
});
|
||||
};
|
||||
|
||||
export const watchChats =
|
||||
(): OneWayWebSocket<TypesGen.ServerSentEvent> => {
|
||||
return new OneWayWebSocket({
|
||||
apiRoute: "/api/v2/chats/watch",
|
||||
});
|
||||
};
|
||||
|
||||
export const watchAgentContainers = (
|
||||
agentId: string,
|
||||
): OneWayWebSocket<TypesGen.WorkspaceAgentListContainersResponse> => {
|
||||
|
||||
Generated
+3389
-4564
File diff suppressed because it is too large
Load Diff
@@ -3,14 +3,13 @@ import {
|
||||
type ModelSelectorOption,
|
||||
} from "components/ai-elements";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { Input } from "components/Input/Input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { ArrowUpIcon, ListPlusIcon, Loader2Icon, Square } from "lucide-react";
|
||||
import { memo, type ReactNode, useCallback, useState } from "react";
|
||||
import { memo, type ReactNode, useCallback, useRef, useState } from "react";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
import { formatProviderLabel } from "./modelOptions";
|
||||
|
||||
@@ -53,9 +52,7 @@ interface AgentChatInputProps {
|
||||
// Extra controls rendered in the left action area (e.g. workspace
|
||||
// selector on the create page).
|
||||
leftActions?: ReactNode;
|
||||
// Optional context compression threshold percentage input (0-100).
|
||||
contextCompressionThreshold?: string;
|
||||
onContextCompressionThresholdChange?: (value: string) => void;
|
||||
|
||||
// Optional context-usage summary shown to the left of the send button.
|
||||
// Pass `null` to render fallback values (e.g. when limit is unknown).
|
||||
// Omit entirely to hide the indicator.
|
||||
@@ -240,12 +237,11 @@ export const AgentChatInput = memo<AgentChatInputProps>(
|
||||
isInterruptPending = false,
|
||||
hasQueuedMessages = false,
|
||||
leftActions,
|
||||
contextCompressionThreshold,
|
||||
onContextCompressionThresholdChange,
|
||||
contextUsage,
|
||||
contextUsage,
|
||||
sticky = false,
|
||||
}) => {
|
||||
const [input, setInput] = useState(initialValue);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
@@ -258,6 +254,9 @@ export const AgentChatInput = memo<AgentChatInputProps>(
|
||||
onInputChange?.("");
|
||||
} catch {
|
||||
// Keep input on failure so the user can retry.
|
||||
} finally {
|
||||
// Re-focus the textarea so the user can keep typing.
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [input, isDisabled, hasModelOptions, onSend, onInputChange]);
|
||||
|
||||
@@ -275,6 +274,7 @@ export const AgentChatInput = memo<AgentChatInputProps>(
|
||||
<div className="mx-auto w-full max-w-3xl pb-4">
|
||||
<div className="rounded-2xl border border-border-default/80 bg-surface-secondary/45 p-1 shadow-sm focus-within:ring-2 focus-within:ring-content-link/40">
|
||||
<TextareaAutosize
|
||||
ref={textareaRef}
|
||||
className="min-h-[120px] w-full resize-none border-none bg-transparent px-3 py-2 font-sans text-[15px] leading-6 text-content-primary outline-none placeholder:text-content-secondary disabled:cursor-not-allowed disabled:opacity-70"
|
||||
placeholder={placeholder}
|
||||
value={input}
|
||||
@@ -300,26 +300,6 @@ export const AgentChatInput = memo<AgentChatInputProps>(
|
||||
className="h-8 w-auto justify-start border-none bg-transparent px-1 text-xs shadow-none hover:bg-transparent [&>span]:!text-content-secondary"
|
||||
/>
|
||||
{leftActions}
|
||||
{onContextCompressionThresholdChange &&
|
||||
contextCompressionThreshold !== undefined && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={contextCompressionThreshold}
|
||||
onChange={(event) =>
|
||||
onContextCompressionThresholdChange(
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
className="h-7 w-16 border-border-default/70 bg-transparent px-2 text-xs"
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<span className="text-xs text-content-secondary">%</span>
|
||||
</div>
|
||||
)}
|
||||
{inputStatusText && (
|
||||
<span className="hidden text-xs text-content-secondary sm:inline">
|
||||
{inputStatusText}
|
||||
|
||||
@@ -92,23 +92,6 @@ const asNonEmptyString = (value: unknown): string | undefined => {
|
||||
return next.length > 0 ? next : undefined;
|
||||
};
|
||||
|
||||
const defaultContextCompressionThreshold = "70";
|
||||
|
||||
const contextCompressionThresholdStorageKey = (modelID: string): string =>
|
||||
`agents.context-compression-threshold.${modelID || "default"}`;
|
||||
|
||||
const parseContextCompressionThreshold = (
|
||||
value: string,
|
||||
): number | undefined => {
|
||||
const parsedValue = Number.parseInt(value.trim(), 10);
|
||||
if (!Number.isFinite(parsedValue)) {
|
||||
return undefined;
|
||||
}
|
||||
if (parsedValue < 0 || parsedValue > 100) {
|
||||
return undefined;
|
||||
}
|
||||
return parsedValue;
|
||||
};
|
||||
|
||||
type ChatMessageWithUsage = TypesGen.ChatMessage & {
|
||||
readonly input_tokens?: unknown;
|
||||
@@ -890,23 +873,6 @@ const resolveModelFromChatConfig = (
|
||||
return modelOptions[0]?.id ?? "";
|
||||
};
|
||||
|
||||
const resolveContextCompressionThresholdFromChatConfig = (
|
||||
modelConfig: TypesGen.Chat["model_config"],
|
||||
): string | null => {
|
||||
if (!modelConfig || typeof modelConfig !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const typedModelConfig = modelConfig as Record<string, unknown>;
|
||||
const threshold = typedModelConfig.context_compression_threshold;
|
||||
if (typeof threshold !== "number" || !Number.isFinite(threshold)) {
|
||||
return null;
|
||||
}
|
||||
if (threshold < 0 || threshold > 100) {
|
||||
return null;
|
||||
}
|
||||
return String(Math.trunc(threshold));
|
||||
};
|
||||
|
||||
type StreamToolCall = {
|
||||
id: string;
|
||||
@@ -1175,8 +1141,6 @@ export const AgentDetail: FC = () => {
|
||||
Map<string, TypesGen.ChatStatus>
|
||||
>(new Map());
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [contextCompressionThreshold, setContextCompressionThreshold] =
|
||||
useState(defaultContextCompressionThreshold);
|
||||
const chatErrorReasons = outletContext?.chatErrorReasons ?? {};
|
||||
const setChatErrorReason =
|
||||
outletContext?.setChatErrorReason ?? noopSetChatErrorReason;
|
||||
@@ -1330,33 +1294,6 @@ export const AgentDetail: FC = () => {
|
||||
});
|
||||
}, [chatQuery.data, modelOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatQuery.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const configuredModel = resolveModelFromChatConfig(
|
||||
chatQuery.data.chat.model_config,
|
||||
modelOptions,
|
||||
);
|
||||
const fromChatConfig = resolveContextCompressionThresholdFromChatConfig(
|
||||
chatQuery.data.chat.model_config,
|
||||
);
|
||||
if (fromChatConfig !== null && selectedModel === configuredModel) {
|
||||
setContextCompressionThreshold(fromChatConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const storedThreshold = localStorage.getItem(
|
||||
contextCompressionThresholdStorageKey(selectedModel),
|
||||
);
|
||||
setContextCompressionThreshold(
|
||||
storedThreshold ?? defaultContextCompressionThreshold,
|
||||
);
|
||||
}, [chatQuery.data, modelOptions, selectedModel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId) {
|
||||
@@ -1700,19 +1637,12 @@ export const AgentDetail: FC = () => {
|
||||
: "No models configured. Ask an admin.";
|
||||
const isSubmissionPending =
|
||||
sendMutation.isPending || interruptMutation.isPending;
|
||||
const isInputDisabled = isSubmissionPending || !hasModelOptions;
|
||||
const handleContextCompressionThresholdChange = useCallback(
|
||||
(value: string) => {
|
||||
setContextCompressionThreshold(value);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(
|
||||
contextCompressionThresholdStorageKey(selectedModel),
|
||||
value,
|
||||
);
|
||||
}
|
||||
},
|
||||
[selectedModel],
|
||||
);
|
||||
// Only disable the input when models are unavailable — NOT while a
|
||||
// mutation is in flight. Disabling the textarea causes the browser
|
||||
// to blur it, which means the user can't keep typing after sending
|
||||
// a message (especially queued messages). The submit handler
|
||||
// already guards against double-submission.
|
||||
const isInputDisabled = !hasModelOptions;
|
||||
|
||||
// Stable callback refs — the actual implementation is updated on
|
||||
// every render, but the reference passed to ChatInput never changes.
|
||||
@@ -1730,14 +1660,10 @@ export const AgentDetail: FC = () => {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const parsedCompressionThreshold = parseContextCompressionThreshold(
|
||||
contextCompressionThreshold,
|
||||
);
|
||||
const request: CreateChatMessagePayload = {
|
||||
role: "user",
|
||||
content: JSON.parse(JSON.stringify(message)),
|
||||
model: selectedModel || undefined,
|
||||
context_compression_threshold: parsedCompressionThreshold,
|
||||
};
|
||||
clearChatErrorReason(agentId);
|
||||
setStreamError(null);
|
||||
@@ -2217,10 +2143,6 @@ export const AgentDetail: FC = () => {
|
||||
modelSelectorPlaceholder={modelSelectorPlaceholder}
|
||||
inputStatusText={inputStatusText}
|
||||
modelCatalogStatusMessage={modelCatalogStatusMessage}
|
||||
contextCompressionThreshold={contextCompressionThreshold}
|
||||
onContextCompressionThresholdChange={
|
||||
handleContextCompressionThresholdChange
|
||||
}
|
||||
sticky
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ChatModelsResponse } from "api/api";
|
||||
import { watchChats } from "api/api";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import { chatModels, chats, createChat, deleteChat } from "api/queries/chats";
|
||||
import { chatModels, chats, chatsKey, createChat, deleteChat } from "api/queries/chats";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { deploymentConfig } from "api/queries/deployment";
|
||||
import { workspaces } from "api/queries/workspaces";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
@@ -11,7 +13,6 @@ import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "components/Dialog/Dialog";
|
||||
@@ -26,7 +27,7 @@ import {
|
||||
import { ScrollArea } from "components/ScrollArea/ScrollArea";
|
||||
import { useAuthenticated } from "hooks";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { BoxesIcon, KeyRoundIcon, Loader2Icon, MonitorIcon, UserIcon, XIcon } from "lucide-react";
|
||||
import { BoxesIcon, KeyRoundIcon, MonitorIcon, UserIcon, XIcon } from "lucide-react";
|
||||
import { UserDropdown } from "modules/dashboard/Navbar/UserDropdown/UserDropdown";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import {
|
||||
@@ -56,23 +57,6 @@ import {
|
||||
const emptyInputStorageKey = "agents.empty-input";
|
||||
const selectedWorkspaceIdStorageKey = "agents.selected-workspace-id";
|
||||
const selectedWorkspaceModeStorageKey = "agents.selected-workspace-mode";
|
||||
const defaultContextCompressionThreshold = "70";
|
||||
|
||||
const contextCompressionThresholdStorageKey = (modelID: string) =>
|
||||
`agents.context-compression-threshold.${modelID || "default"}`;
|
||||
|
||||
const parseContextCompressionThreshold = (
|
||||
value: string,
|
||||
): number | undefined => {
|
||||
const parsedValue = Number.parseInt(value.trim(), 10);
|
||||
if (!Number.isFinite(parsedValue)) {
|
||||
return undefined;
|
||||
}
|
||||
if (parsedValue < 0 || parsedValue > 100) {
|
||||
return undefined;
|
||||
}
|
||||
return parsedValue;
|
||||
};
|
||||
|
||||
type ChatModelOption = ModelSelectorOption;
|
||||
|
||||
@@ -82,7 +66,6 @@ type CreateChatOptions = {
|
||||
workspaceMode?: "local";
|
||||
model?: string;
|
||||
systemPrompt?: string;
|
||||
contextCompressionThreshold?: number;
|
||||
};
|
||||
|
||||
type ConfigureAgentsSection = "providers" | "system-prompt" | "models";
|
||||
@@ -123,7 +106,7 @@ export const AgentsPage: FC = () => {
|
||||
});
|
||||
const createMutation = useMutation(createChat(queryClient));
|
||||
const archiveMutation = useMutation(deleteChat(queryClient));
|
||||
const [archiveTargetChatId, setArchiveTargetChatId] = useState<string | null>(
|
||||
const [archivingChatId, setArchivingChatId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
|
||||
@@ -165,9 +148,36 @@ export const AgentsPage: FC = () => {
|
||||
const topBarTitleRef = useRef<HTMLDivElement>(null);
|
||||
const topBarActionsRef = useRef<HTMLDivElement>(null);
|
||||
const rightPanelRef = useRef<HTMLDivElement>(null);
|
||||
const requestArchiveAgent = useCallback((chatId: string) => {
|
||||
setArchiveTargetChatId(chatId);
|
||||
}, []);
|
||||
const chatList = chatsQuery.data ?? [];
|
||||
const requestArchiveAgent = useCallback(
|
||||
async (chatId: string) => {
|
||||
if (archiveMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
setArchivingChatId(chatId);
|
||||
const nextChatId = (queryClient.getQueryData(chats().queryKey) as typeof chatList)?.find(
|
||||
(chat) => chat.id !== chatId,
|
||||
)?.id;
|
||||
|
||||
try {
|
||||
await archiveMutation.mutateAsync(chatId);
|
||||
clearChatErrorReason(chatId);
|
||||
displaySuccess("Agent archived.");
|
||||
|
||||
if (chatId === agentId) {
|
||||
navigate(nextChatId ? `/agents/${nextChatId}` : "/agents", {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
displayError(getErrorMessage(error, "Failed to archive agent."));
|
||||
} finally {
|
||||
setArchivingChatId(null);
|
||||
}
|
||||
},
|
||||
[archiveMutation, queryClient, chatList, agentId, navigate, clearChatErrorReason],
|
||||
);
|
||||
const outletContext: AgentsOutletContext = {
|
||||
chatErrorReasons,
|
||||
setChatErrorReason,
|
||||
@@ -189,7 +199,6 @@ export const AgentsPage: FC = () => {
|
||||
workspaceMode,
|
||||
model,
|
||||
systemPrompt,
|
||||
contextCompressionThreshold,
|
||||
} = options;
|
||||
const createdChat = await createMutation.mutateAsync({
|
||||
message,
|
||||
@@ -200,7 +209,6 @@ export const AgentsPage: FC = () => {
|
||||
workspace_mode: workspaceMode,
|
||||
model,
|
||||
system_prompt: systemPrompt,
|
||||
context_compression_threshold: contextCompressionThreshold,
|
||||
});
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -217,6 +225,53 @@ export const AgentsPage: FC = () => {
|
||||
navigate("/agents");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const ws = watchChats();
|
||||
ws.addEventListener("message", (event) => {
|
||||
const sse = event.parsedMessage;
|
||||
if (sse?.type !== "data" || !sse.data) {
|
||||
return;
|
||||
}
|
||||
const chatEvent = sse.data as {
|
||||
kind: string;
|
||||
chat: TypesGen.Chat;
|
||||
};
|
||||
if (!chatEvent?.chat?.id) {
|
||||
return;
|
||||
}
|
||||
const updatedChat = chatEvent.chat;
|
||||
|
||||
if (chatEvent.kind === "deleted") {
|
||||
queryClient.setQueryData(
|
||||
chatsKey,
|
||||
(prev: TypesGen.Chat[] | undefined) =>
|
||||
prev?.filter((c) => c.id !== updatedChat.id),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData(
|
||||
chatsKey,
|
||||
(prev: TypesGen.Chat[] | undefined) => {
|
||||
if (!prev) return prev;
|
||||
const exists = prev.some((c) => c.id === updatedChat.id);
|
||||
if (exists) {
|
||||
return prev.map((c) =>
|
||||
c.id === updatedChat.id
|
||||
? { ...c, status: updatedChat.status, title: updatedChat.title, updated_at: updatedChat.updated_at }
|
||||
: c,
|
||||
);
|
||||
}
|
||||
if (chatEvent.kind === "created") {
|
||||
return [updatedChat, ...prev];
|
||||
}
|
||||
return prev;
|
||||
},
|
||||
);
|
||||
});
|
||||
return () => ws.close();
|
||||
}, [queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = pageTitle("Agents");
|
||||
}, []);
|
||||
@@ -227,42 +282,6 @@ export const AgentsPage: FC = () => {
|
||||
}
|
||||
}, [agentId]);
|
||||
|
||||
const chatList = chatsQuery.data ?? [];
|
||||
const archiveTargetChat = archiveTargetChatId
|
||||
? chatList.find((chat) => chat.id === archiveTargetChatId)
|
||||
: undefined;
|
||||
|
||||
const handleCloseArchiveDialog = () => {
|
||||
if (archiveMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
setArchiveTargetChatId(null);
|
||||
};
|
||||
|
||||
const handleConfirmArchive = async () => {
|
||||
if (!archiveTargetChatId || archiveMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const archivedChatId = archiveTargetChatId;
|
||||
const nextChatId = chatList.find((chat) => chat.id !== archivedChatId)?.id;
|
||||
|
||||
try {
|
||||
await archiveMutation.mutateAsync(archivedChatId);
|
||||
setArchiveTargetChatId(null);
|
||||
clearChatErrorReason(archivedChatId);
|
||||
displaySuccess("Agent archived.");
|
||||
|
||||
if (archivedChatId === agentId) {
|
||||
navigate(nextChatId ? `/agents/${nextChatId}` : "/agents", {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
displayError(getErrorMessage(error, "Failed to archive agent."));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-surface-primary md:flex-row">
|
||||
<div
|
||||
@@ -280,7 +299,7 @@ export const AgentsPage: FC = () => {
|
||||
onNewAgent={handleNewAgent}
|
||||
isCreating={createMutation.isPending}
|
||||
isArchiving={archiveMutation.isPending}
|
||||
archivingChatId={archiveTargetChatId}
|
||||
archivingChatId={archivingChatId}
|
||||
isLoading={chatsQuery.isLoading}
|
||||
loadError={chatsQuery.isError ? chatsQuery.error : undefined}
|
||||
onRetryLoad={() => void chatsQuery.refetch()}
|
||||
@@ -340,46 +359,7 @@ export const AgentsPage: FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={archiveTargetChatId !== null}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
handleCloseArchiveDialog();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md p-6">
|
||||
<DialogHeader className="space-y-2">
|
||||
<DialogTitle>Archive agent</DialogTitle>
|
||||
<DialogDescription>
|
||||
{archiveTargetChat
|
||||
? `Archive "${archiveTargetChat.title}"? This permanently deletes its chat history.`
|
||||
: "Archive this agent? This permanently deletes its chat history."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCloseArchiveDialog}
|
||||
disabled={archiveMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => void handleConfirmArchive()}
|
||||
disabled={archiveMutation.isPending}
|
||||
>
|
||||
{archiveMutation.isPending && (
|
||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Archive
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -418,8 +398,6 @@ const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
|
||||
return localStorage.getItem(emptyInputStorageKey) ?? "";
|
||||
}, []);
|
||||
const [selectedModel, setSelectedModel] = useState(modelOptions[0]?.id ?? "");
|
||||
const [contextCompressionThreshold, setContextCompressionThreshold] =
|
||||
useState(defaultContextCompressionThreshold);
|
||||
const [systemPrompt, setSystemPrompt] = useState("");
|
||||
const [isConfigureAgentsDialogOpen, setConfigureAgentsDialogOpen] =
|
||||
useState(false);
|
||||
@@ -497,8 +475,6 @@ const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
|
||||
selectedWorkspaceModeRef.current = selectedWorkspaceMode;
|
||||
const selectedModelRef = useRef(selectedModel);
|
||||
selectedModelRef.current = selectedModel;
|
||||
const contextCompressionThresholdRef = useRef(contextCompressionThreshold);
|
||||
contextCompressionThresholdRef.current = contextCompressionThreshold;
|
||||
const systemPromptRef = useRef(systemPrompt);
|
||||
systemPromptRef.current = systemPrompt;
|
||||
|
||||
@@ -511,17 +487,6 @@ const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
|
||||
});
|
||||
}, [modelOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const storedThreshold = localStorage.getItem(
|
||||
contextCompressionThresholdStorageKey(selectedModel),
|
||||
);
|
||||
setContextCompressionThreshold(
|
||||
storedThreshold ?? defaultContextCompressionThreshold,
|
||||
);
|
||||
}, [selectedModel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -575,26 +540,11 @@ const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleContextCompressionThresholdChange = useCallback(
|
||||
(value: string) => {
|
||||
setContextCompressionThreshold(value);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(
|
||||
contextCompressionThresholdStorageKey(selectedModelRef.current),
|
||||
value,
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (message: string) => {
|
||||
const trimmedSystemPrompt = systemPromptRef.current.trim();
|
||||
const localWorkspaceMode = selectedWorkspaceModeRef.current === "local";
|
||||
const parsedCompressionThreshold = parseContextCompressionThreshold(
|
||||
contextCompressionThresholdRef.current,
|
||||
);
|
||||
await onCreateChat({
|
||||
message,
|
||||
workspaceId: localWorkspaceMode
|
||||
@@ -606,7 +556,6 @@ const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
|
||||
canSetSystemPrompt && trimmedSystemPrompt
|
||||
? trimmedSystemPrompt
|
||||
: undefined,
|
||||
contextCompressionThreshold: parsedCompressionThreshold,
|
||||
});
|
||||
},
|
||||
[onCreateChat, canSetSystemPrompt],
|
||||
@@ -661,10 +610,6 @@ const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
|
||||
hasModelOptions={hasModelOptions}
|
||||
inputStatusText={inputStatusText}
|
||||
modelCatalogStatusMessage={modelCatalogStatusMessage}
|
||||
contextCompressionThreshold={contextCompressionThreshold}
|
||||
onContextCompressionThresholdChange={
|
||||
handleContextCompressionThresholdChange
|
||||
}
|
||||
leftActions={
|
||||
<Select
|
||||
value={
|
||||
|
||||
Reference in New Issue
Block a user