Merge branch 'dev' into feature/project-index-keybinds
This commit is contained in:
@@ -355,8 +355,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "مفتاح API مفقود.",
|
||||
"zen.api.error.invalidApiKey": "مفتاح API غير صالح.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "تم تجاوز حصة الاشتراك. أعد المحاولة خلال {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"تم تجاوز حصة الاشتراك. يمكنك الاستمرار في استخدام النماذج المجانية.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"تم الوصول إلى حد الاستخدام لمدة 5 ساعات. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"تم الوصول إلى حد الاستخدام الأسبوعي. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"تم الوصول إلى حد الاستخدام الشهري. تتم إعادة التعيين خلال {{retryIn}}. لمواصلة استخدام هذا النموذج الآن، فعّل الاستخدام من رصيدك المتاح: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "لا توجد طريقة دفع. أضف طريقة دفع هنا: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "رصيد غير كاف. إدارة فواتيرك هنا: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -363,8 +363,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "Chave de API ausente.",
|
||||
"zen.api.error.invalidApiKey": "Chave de API inválida.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Cota de assinatura excedida. Tente novamente em {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Cota de assinatura excedida. Você pode continuar usando modelos gratuitos.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Limite de uso de 5 horas atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Limite de uso semanal atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Limite de uso mensal atingido. Será reiniciado em {{retryIn}}. Para continuar usando este modelo agora, habilite o uso a partir do seu saldo disponível: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "Nenhuma forma de pagamento. Adicione uma forma de pagamento aqui: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Saldo insuficiente. Gerencie seu faturamento aqui: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -359,8 +359,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "Manglende API-nøgle.",
|
||||
"zen.api.error.invalidApiKey": "Ugyldig API-nøgle.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igen om {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Abonnementskvote overskredet. Du kan fortsætte med at bruge gratis modeller.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Forbrugsgrænsen for 5 timer er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Ugentlig forbrugsgrænse er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Månedlig forbrugsgrænse er nået. Nulstilles om {{retryIn}}. For at fortsætte med at bruge denne model nu, aktivér forbrug fra din tilgængelige saldo: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "Ingen betalingsmetode. Tilføj en betalingsmetode her: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Utilstrækkelig saldo. Administrer din fakturering her: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -362,8 +362,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "Fehlender API-Key.",
|
||||
"zen.api.error.invalidApiKey": "Ungültiger API-Key.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Abonnement-Quote überschritten. Erneuter Versuch in {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Abonnement-Quote überschritten. Du kannst weiterhin kostenlose Modelle nutzen.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"5-Stunden-Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Wöchentliches Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Monatliches Nutzungslimit erreicht. Wird in {{retryIn}} zurückgesetzt. Um dieses Modell jetzt weiter zu nutzen, aktiviere die Nutzung über dein verfügbares Guthaben: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "Keine Zahlungsmethode. Füge hier eine Zahlungsmethode hinzu: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Unzureichendes Guthaben. Verwalte deine Abrechnung hier: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -355,8 +355,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "Missing API key.",
|
||||
"zen.api.error.invalidApiKey": "Invalid API key.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Subscription quota exceeded. Retry in {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Subscription quota exceeded. You can continue using free models.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"5-hour usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Weekly usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Monthly usage limit reached. Resets in {{retryIn}}. To continue using this model now, enable usage from your available balance: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "No payment method. Add a payment method here: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Insufficient balance. Manage your billing here: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -363,8 +363,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "Falta la clave API.",
|
||||
"zen.api.error.invalidApiKey": "Clave API inválida.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Cuota de suscripción excedida. Reintenta en {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Cuota de suscripción excedida. Puedes continuar usando modelos gratuitos.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Límite de uso de 5 horas alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Límite de uso semanal alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Límite de uso mensual alcanzado. Se restablece en {{retryIn}}. Para seguir usando este modelo ahora, habilita el uso desde tu saldo disponible: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "Sin método de pago. Añade un método de pago aquí: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Saldo insuficiente. Gestiona tu facturación aquí: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -363,8 +363,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "Clé API manquante.",
|
||||
"zen.api.error.invalidApiKey": "Clé API invalide.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Quota d'abonnement dépassé. Réessayez dans {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Quota d'abonnement dépassé. Vous pouvez continuer à utiliser les modèles gratuits.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Limite d'utilisation sur 5 heures atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Limite d'utilisation hebdomadaire atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Limite d'utilisation mensuelle atteinte. Réinitialisation dans {{retryIn}}. Pour continuer à utiliser ce modèle dès maintenant, activez l'utilisation depuis votre solde disponible : {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "Aucune méthode de paiement. Ajoutez une méthode de paiement ici : {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Solde insuffisant. Gérez votre facturation ici : {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -359,8 +359,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "Chiave API mancante.",
|
||||
"zen.api.error.invalidApiKey": "Chiave API non valida.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Quota dell'abbonamento superata. Riprova tra {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Quota dell'abbonamento superata. Puoi continuare a utilizzare modelli gratuiti.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Limite di utilizzo di 5 ore raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Limite di utilizzo settimanale raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Limite di utilizzo mensile raggiunto. Si reimposta tra {{retryIn}}. Per continuare a usare questo modello ora, abilita l'utilizzo dal tuo saldo disponibile: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "Nessun metodo di pagamento. Aggiungi un metodo di pagamento qui: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Saldo insufficiente. Gestisci la tua fatturazione qui: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -360,8 +360,12 @@ export const dict = {
|
||||
"zen.api.error.invalidApiKey": "無効なAPIキーです。",
|
||||
"zen.api.error.subscriptionQuotaExceeded":
|
||||
"サブスクリプションの制限を超えました。{{retryIn}} 後に再試行してください。",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"サブスクリプションの制限を超えました。無料モデルは引き続きご利用いただけます。",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"5時間の利用上限に達しました。{{retryIn}} 後にリセットされます。今すぐこのモデルの利用を続けるには、利用可能な残高からの利用を有効化してください: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"週間の利用上限に達しました。{{retryIn}} 後にリセットされます。今すぐこのモデルの利用を続けるには、利用可能な残高からの利用を有効化してください: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"月間の利用上限に達しました。{{retryIn}} 後にリセットされます。今すぐこのモデルの利用を続けるには、利用可能な残高からの利用を有効化してください: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "お支払い方法がありません。こちらからお支払い方法を追加してください: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "残高が不足しています。こちらから請求を管理してください: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -354,8 +354,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "API 키가 누락되었습니다.",
|
||||
"zen.api.error.invalidApiKey": "유효하지 않은 API 키입니다.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "구독 할당량을 초과했습니다. {{retryIn}} 후 다시 시도해 주세요.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"구독 할당량을 초과했습니다. 무료 모델은 계속 사용할 수 있습니다.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"5시간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"주간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"월간 사용 한도에 도달했습니다. {{retryIn}} 후 초기화됩니다. 이 모델을 지금 계속 사용하려면 사용 가능한 잔액에서 사용을 활성화하세요: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "결제 수단이 없습니다. 결제 수단을 추가하세요: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "잔액이 부족합니다. 결제 관리를 여기서 하세요: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -360,8 +360,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "Mangler API-nøkkel.",
|
||||
"zen.api.error.invalidApiKey": "Ugyldig API-nøkkel.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Abonnementskvote overskredet. Prøv igjen om {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Abonnementskvote overskredet. Du kan fortsette å bruke gratis modeller.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"5-timers bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Ukentlig bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Månedlig bruksgrense nådd. Tilbakestilles om {{retryIn}}. For å fortsette å bruke denne modellen nå, aktiver bruk fra din tilgjengelige saldo: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "Ingen betalingsmetode. Legg til en betalingsmetode her: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Utilstrekkelig saldo. Administrer faktureringen din her: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -361,8 +361,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "Brak klucza API.",
|
||||
"zen.api.error.invalidApiKey": "Nieprawidłowy klucz API.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Przekroczono limit subskrypcji. Spróbuj ponownie za {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Przekroczono limit subskrypcji. Możesz kontynuować korzystanie z darmowych modeli.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Osiągnięto 5-godzinny limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Osiągnięto tygodniowy limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Osiągnięto miesięczny limit użycia. Resetuje się za {{retryIn}}. Aby nadal korzystać z tego modelu, włącz użycie z dostępnego salda: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "Brak metody płatności. Dodaj metodę płatności tutaj: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Niewystarczające saldo. Zarządzaj swoimi płatnościami tutaj: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -365,8 +365,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "Отсутствует API ключ.",
|
||||
"zen.api.error.invalidApiKey": "Неверный API ключ.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Квота подписки превышена. Повторите попытку через {{retryIn}}.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Квота подписки превышена. Вы можете продолжить использовать бесплатные модели.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"Достигнут лимит использования за 5 часов. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Достигнут недельный лимит использования. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Достигнут месячный лимит использования. Сбросится через {{retryIn}}. Чтобы продолжить использовать эту модель сейчас, включите оплату с доступного баланса: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "Нет способа оплаты. Добавьте способ оплаты здесь: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Недостаточно средств. Управляйте оплатой здесь: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -356,8 +356,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "ไม่มี API key",
|
||||
"zen.api.error.invalidApiKey": "API key ไม่ถูกต้อง",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "โควต้าการสมัครสมาชิกเกินขีดจำกัด ลองใหม่ในอีก {{retryIn}}",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"โควต้าการสมัครสมาชิกเกินขีดจำกัด คุณสามารถดำเนินการต่อโดยใช้โมเดลฟรี",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"ถึงขีดจำกัดการใช้งานในรอบ 5 ชั่วโมงแล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"ถึงขีดจำกัดการใช้งานรายสัปดาห์แล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"ถึงขีดจำกัดการใช้งานรายเดือนแล้ว จะรีเซ็ตในอีก {{retryIn}} หากต้องการใช้โมเดลนี้ต่อทันที ให้เปิดใช้งานจากยอดเงินคงเหลือของคุณ: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "ไม่มีวิธีการชำระเงิน เพิ่มวิธีการชำระเงินที่นี่: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "ยอดเงินคงเหลือไม่เพียงพอ จัดการการเรียกเก็บเงินของคุณที่นี่: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -363,8 +363,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "API anahtarı eksik.",
|
||||
"zen.api.error.invalidApiKey": "Geçersiz API anahtarı.",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "Abonelik kotası aşıldı. {{retryIn}} içinde tekrar deneyin.",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels":
|
||||
"Abonelik kotası aşıldı. Ücretsiz modelleri kullanmaya devam edebilirsiniz.",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"5 saatlik kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"Haftalık kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"Aylık kullanım limitine ulaşıldı. {{retryIn}} içinde sıfırlanır. Bu modeli şimdi kullanmaya devam etmek için kullanılabilir bakiyenizden kullanımı etkinleştirin: {{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "Ödeme yöntemi bulunamadı. Buradan bir ödeme yöntemi ekleyin: {{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "Yetersiz bakiye. Faturalandırmanızı buradan yönetin: {{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -343,7 +343,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "缺少 API 密钥。",
|
||||
"zen.api.error.invalidApiKey": "无效的 API 密钥。",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "超出订阅配额。请在 {{retryIn}} 后重试。",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels": "超出订阅配额。您可以继续使用免费模型。",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"已达到 5 小时使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"已达到每周使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"已达到每月使用限额。将在 {{retryIn}} 后重置。如需立即继续使用该模型,请启用从可用余额扣费:{{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "没有付款方式。请在此处添加付款方式:{{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "余额不足。请在此处管理您的计费:{{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -343,7 +343,12 @@ export const dict = {
|
||||
"zen.api.error.missingApiKey": "缺少 API 金鑰。",
|
||||
"zen.api.error.invalidApiKey": "無效的 API 金鑰。",
|
||||
"zen.api.error.subscriptionQuotaExceeded": "超出訂閱配額。請在 {{retryIn}} 後重試。",
|
||||
"zen.api.error.subscriptionQuotaExceededUseFreeModels": "超出訂閱配額。你可以繼續使用免費模型。",
|
||||
"zen.api.error.goSubscriptionRollingLimitExceeded":
|
||||
"已達 5 小時使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionWeeklyLimitExceeded":
|
||||
"已達每週使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}",
|
||||
"zen.api.error.goSubscriptionMonthlyLimitExceeded":
|
||||
"已達每月使用上限,將在 {{retryIn}} 後重置。若要立即繼續使用此模型,請從可用餘額啟用使用量:{{consoleGoUrl}}",
|
||||
"zen.api.error.noPaymentMethod": "無付款方式。請在此處新增付款方式:{{billingUrl}}",
|
||||
"zen.api.error.insufficientBalance": "餘額不足。請在此處管理你的帳務:{{billingUrl}}",
|
||||
"zen.api.error.workspaceMonthlyLimitReached":
|
||||
|
||||
@@ -744,6 +744,7 @@ export async function handler(
|
||||
// Validate lite subscription billing
|
||||
if (opts.modelList === "lite" && authInfo.billing.lite && authInfo.lite) {
|
||||
try {
|
||||
const consoleGoUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/go`
|
||||
const sub = authInfo.lite
|
||||
const liteData = LiteData.getLimits()
|
||||
|
||||
@@ -756,7 +757,10 @@ export async function handler(
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new GoUsageLimitError(
|
||||
t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
|
||||
t("zen.api.error.goSubscriptionWeeklyLimitExceeded", {
|
||||
retryIn: formatRetryTime(result.resetInSec),
|
||||
consoleGoUrl,
|
||||
}),
|
||||
authInfo.workspaceID,
|
||||
"weekly",
|
||||
result.resetInSec,
|
||||
@@ -773,7 +777,10 @@ export async function handler(
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new GoUsageLimitError(
|
||||
t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
|
||||
t("zen.api.error.goSubscriptionMonthlyLimitExceeded", {
|
||||
retryIn: formatRetryTime(result.resetInSec),
|
||||
consoleGoUrl,
|
||||
}),
|
||||
authInfo.workspaceID,
|
||||
"monthly",
|
||||
result.resetInSec,
|
||||
@@ -790,7 +797,10 @@ export async function handler(
|
||||
})
|
||||
if (result.status === "rate-limited")
|
||||
throw new GoUsageLimitError(
|
||||
t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
|
||||
t("zen.api.error.goSubscriptionRollingLimitExceeded", {
|
||||
retryIn: formatRetryTime(result.resetInSec),
|
||||
consoleGoUrl,
|
||||
}),
|
||||
authInfo.workspaceID,
|
||||
"5 hour",
|
||||
result.resetInSec,
|
||||
|
||||
+258
-392
@@ -7,38 +7,9 @@ import { homedir, tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { getCACertificates, setDefaultCACertificates } from "node:tls"
|
||||
import type { Event } from "electron"
|
||||
import { app, BrowserWindow, dialog } from "electron"
|
||||
import pkg from "electron-updater"
|
||||
import { app, BrowserWindow } from "electron"
|
||||
|
||||
import contextMenu from "electron-context-menu"
|
||||
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
|
||||
|
||||
// on macOS apps run in `/` which can cause issues with ripgrep
|
||||
try {
|
||||
process.chdir(homedir())
|
||||
} catch {}
|
||||
|
||||
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
|
||||
|
||||
const APP_NAMES: Record<string, string> = {
|
||||
dev: "OpenCode Dev",
|
||||
beta: "OpenCode Beta",
|
||||
prod: "OpenCode",
|
||||
}
|
||||
const APP_IDS: Record<string, string> = {
|
||||
dev: "ai.opencode.desktop.dev",
|
||||
beta: "ai.opencode.desktop.beta",
|
||||
prod: "ai.opencode.desktop",
|
||||
}
|
||||
const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1"
|
||||
const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"
|
||||
const onboardingTestRoot = setupOnboardingTestEnv()
|
||||
app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev")
|
||||
app.setAppUserModelId(appId)
|
||||
app.setPath("userData", onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId))
|
||||
if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session"))
|
||||
const logger = initLogging()
|
||||
const { autoUpdater } = pkg
|
||||
|
||||
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
|
||||
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
|
||||
@@ -64,104 +35,30 @@ import {
|
||||
setDockIcon,
|
||||
} from "./windows"
|
||||
import { migrate } from "./migrate"
|
||||
import { checkUpdate, checkForUpdates, installUpdate, setupAutoUpdater } from "./updater"
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
|
||||
const APP_NAMES: Record<string, string> = {
|
||||
dev: "OpenCode Dev",
|
||||
beta: "OpenCode Beta",
|
||||
prod: "OpenCode",
|
||||
}
|
||||
const APP_IDS: Record<string, string> = {
|
||||
dev: "ai.opencode.desktop.dev",
|
||||
beta: "ai.opencode.desktop.beta",
|
||||
prod: "ai.opencode.desktop",
|
||||
}
|
||||
const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1"
|
||||
|
||||
let logger: ReturnType<typeof initLogging>
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let server: SidecarListener | null = null
|
||||
|
||||
const initEmitter = new EventEmitter()
|
||||
let initStep: InitStep = { phase: "server_waiting" }
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let server: SidecarListener | null = null
|
||||
const loadingComplete = defer<void>()
|
||||
|
||||
const pendingDeepLinks: string[] = []
|
||||
|
||||
const serverReady = defer<ServerReadyData>()
|
||||
|
||||
useSystemCertificates()
|
||||
|
||||
function setupOnboardingTestEnv() {
|
||||
if (!TEST_ONBOARDING) return
|
||||
|
||||
const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) =>
|
||||
mkdirSync(join(root, dir), { recursive: true }),
|
||||
)
|
||||
process.env.OPENCODE_DB = ":memory:"
|
||||
process.env.XDG_DATA_HOME = join(root, "data")
|
||||
process.env.XDG_CONFIG_HOME = join(root, "config")
|
||||
process.env.XDG_CACHE_HOME = join(root, "cache")
|
||||
process.env.XDG_STATE_HOME = join(root, "state")
|
||||
return root
|
||||
}
|
||||
|
||||
logger.log("app starting", {
|
||||
version: app.getVersion(),
|
||||
packaged: app.isPackaged,
|
||||
onboardingTest: Boolean(onboardingTestRoot),
|
||||
})
|
||||
|
||||
setupApp()
|
||||
|
||||
function setupApp() {
|
||||
ensureLoopbackNoProxy()
|
||||
useEnvProxy()
|
||||
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
|
||||
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
|
||||
preferAppEnv(app.getPath("userData"))
|
||||
|
||||
app.on("second-instance", (_event: Event, argv: string[]) => {
|
||||
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
|
||||
if (urls.length) {
|
||||
logger.log("deep link received via second-instance", { urls })
|
||||
emitDeepLinks(urls)
|
||||
}
|
||||
focusMainWindow()
|
||||
})
|
||||
|
||||
app.on("open-url", (event: Event, url: string) => {
|
||||
event.preventDefault()
|
||||
logger.log("deep link received via open-url", { url })
|
||||
emitDeepLinks([url])
|
||||
})
|
||||
|
||||
app.on("before-quit", () => {
|
||||
void killSidecar()
|
||||
})
|
||||
|
||||
app.on("will-quit", () => {
|
||||
void killSidecar()
|
||||
})
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => {
|
||||
void killSidecar().finally(() => app.exit(0))
|
||||
})
|
||||
}
|
||||
|
||||
void app.whenReady().then(async () => {
|
||||
if (!TEST_ONBOARDING) migrate()
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
registerRendererProtocol()
|
||||
setDockIcon()
|
||||
setupAutoUpdater()
|
||||
await initialize()
|
||||
})
|
||||
}
|
||||
|
||||
function useSystemCertificates() {
|
||||
try {
|
||||
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
|
||||
} catch (error) {
|
||||
logger.warn("failed to load system certificates", error)
|
||||
}
|
||||
}
|
||||
|
||||
function useEnvProxy() {
|
||||
try {
|
||||
// Electron 41.2 runs Node 24.14.1; latest @types/node@24 is 24.12.2.
|
||||
@@ -177,145 +74,12 @@ function emitDeepLinks(urls: string[]) {
|
||||
if (mainWindow) sendDeepLinks(mainWindow, urls)
|
||||
}
|
||||
|
||||
function focusMainWindow() {
|
||||
if (!mainWindow) return
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
|
||||
function setInitStep(step: InitStep) {
|
||||
initStep = step
|
||||
logger.log("init step", { step })
|
||||
initEmitter.emit("step", step)
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
const needsMigration = !sqliteFileExists()
|
||||
let overlay: BrowserWindow | null = null
|
||||
|
||||
const port = await getSidecarPort()
|
||||
const hostname = "127.0.0.1"
|
||||
const url = `http://${hostname}:${port}`
|
||||
const password = randomUUID()
|
||||
|
||||
const loadingTask = (async () => {
|
||||
logger.log("sidecar connection started", { url })
|
||||
|
||||
initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => {
|
||||
setInitStep({ phase: "sqlite_waiting" })
|
||||
if (overlay) sendSqliteMigrationProgress(overlay, progress)
|
||||
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
|
||||
})
|
||||
|
||||
logger.log("spawning sidecar", { url })
|
||||
const { listener, health } = await spawnLocalServer(
|
||||
hostname,
|
||||
port,
|
||||
password,
|
||||
() => {
|
||||
ensureLoopbackNoProxy()
|
||||
useEnvProxy()
|
||||
},
|
||||
{
|
||||
needsMigration,
|
||||
userDataPath: app.getPath("userData"),
|
||||
onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress),
|
||||
onStdout: (message) => logger.log("sidecar stdout", { message }),
|
||||
onStderr: (message) => logger.warn("sidecar stderr", { message }),
|
||||
onExit: (code) => logger.warn("sidecar exited", { code }),
|
||||
},
|
||||
)
|
||||
server = listener
|
||||
serverReady.resolve({
|
||||
url,
|
||||
username: "opencode",
|
||||
password,
|
||||
})
|
||||
|
||||
await Promise.race([
|
||||
health.wait,
|
||||
delay(30_000).then(() => {
|
||||
throw new Error("Sidecar health check timed out")
|
||||
}),
|
||||
]).catch((error) => {
|
||||
logger.error("sidecar health check failed", error)
|
||||
})
|
||||
|
||||
logger.log("loading task finished")
|
||||
})()
|
||||
|
||||
if (needsMigration) {
|
||||
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
|
||||
if (show) {
|
||||
overlay = createLoadingWindow()
|
||||
await delay(1_000)
|
||||
}
|
||||
}
|
||||
|
||||
await loadingTask
|
||||
setInitStep({ phase: "done" })
|
||||
|
||||
if (overlay) {
|
||||
await loadingComplete.promise
|
||||
}
|
||||
|
||||
mainWindow = createMainWindow()
|
||||
wireMenu()
|
||||
|
||||
overlay?.close()
|
||||
}
|
||||
|
||||
function wireMenu() {
|
||||
if (!mainWindow) return
|
||||
createMenu({
|
||||
trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
|
||||
checkForUpdates: () => {
|
||||
void checkForUpdates(true)
|
||||
},
|
||||
reload: () => mainWindow?.reload(),
|
||||
relaunch: () => {
|
||||
void killSidecar().finally(() => {
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
registerIpcHandlers({
|
||||
killSidecar: () => killSidecar(),
|
||||
awaitInitialization: async (sendStep) => {
|
||||
sendStep(initStep)
|
||||
const listener = (step: InitStep) => sendStep(step)
|
||||
initEmitter.on("step", listener)
|
||||
try {
|
||||
logger.log("awaiting server ready")
|
||||
const res = await serverReady.promise
|
||||
logger.log("server ready", { url: res.url })
|
||||
return res
|
||||
} finally {
|
||||
initEmitter.off("step", listener)
|
||||
}
|
||||
},
|
||||
getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
|
||||
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
|
||||
getDefaultServerUrl: () => getDefaultServerUrl(),
|
||||
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
|
||||
getWslConfig: () => Promise.resolve(getWslConfig()),
|
||||
setWslConfig: (config: WslConfig) => setWslConfig(config),
|
||||
getDisplayBackend: async () => null,
|
||||
setDisplayBackend: async () => undefined,
|
||||
parseMarkdown: async (markdown) => parseMarkdown(markdown),
|
||||
checkAppExists: (appName) => checkAppExists(appName),
|
||||
wslPath: async (path, mode) => wslPath(path, mode),
|
||||
resolveAppPath: async (appName) => resolveAppPath(appName),
|
||||
loadingWindowComplete: () => loadingComplete.resolve(),
|
||||
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail),
|
||||
checkUpdate: async () => checkUpdate(),
|
||||
installUpdate: async () => installUpdate(),
|
||||
setBackgroundColor: (color) => setBackgroundColor(color),
|
||||
})
|
||||
|
||||
async function killSidecar() {
|
||||
if (!server) return
|
||||
const current = server
|
||||
@@ -343,163 +107,265 @@ function ensureLoopbackNoProxy() {
|
||||
upsert("no_proxy")
|
||||
}
|
||||
|
||||
async function getSidecarPort() {
|
||||
const fromEnv = process.env.OPENCODE_PORT
|
||||
if (fromEnv) {
|
||||
const parsed = Number.parseInt(fromEnv, 10)
|
||||
if (!Number.isNaN(parsed)) return parsed
|
||||
const main = Effect.gen(function* () {
|
||||
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
|
||||
|
||||
// on macOS apps run in `/` which can cause issues with ripgrep
|
||||
try {
|
||||
process.chdir(homedir())
|
||||
} catch {}
|
||||
|
||||
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
|
||||
|
||||
const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"
|
||||
const onboardingTestRoot = ((): string | undefined => {
|
||||
if (!TEST_ONBOARDING) return
|
||||
|
||||
const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) =>
|
||||
mkdirSync(join(root, dir), { recursive: true }),
|
||||
)
|
||||
process.env.OPENCODE_DB = ":memory:"
|
||||
process.env.XDG_DATA_HOME = join(root, "data")
|
||||
process.env.XDG_CONFIG_HOME = join(root, "config")
|
||||
process.env.XDG_CACHE_HOME = join(root, "cache")
|
||||
process.env.XDG_STATE_HOME = join(root, "state")
|
||||
return root
|
||||
})()
|
||||
app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev")
|
||||
app.setAppUserModelId(appId)
|
||||
app.setPath(
|
||||
"userData",
|
||||
onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId),
|
||||
)
|
||||
if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session"))
|
||||
logger = initLogging()
|
||||
|
||||
try {
|
||||
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
|
||||
} catch (error) {
|
||||
logger.warn("failed to load system certificates", error)
|
||||
}
|
||||
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
logger.log("app starting", {
|
||||
version: app.getVersion(),
|
||||
packaged: app.isPackaged,
|
||||
onboardingTest: Boolean(onboardingTestRoot),
|
||||
})
|
||||
|
||||
ensureLoopbackNoProxy()
|
||||
useEnvProxy()
|
||||
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
|
||||
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
|
||||
preferAppEnv(app.getPath("userData"))
|
||||
|
||||
app.on("second-instance", (_event: Event, argv: string[]) => {
|
||||
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
|
||||
if (urls.length) {
|
||||
logger.log("deep link received via second-instance", { urls })
|
||||
emitDeepLinks(urls)
|
||||
}
|
||||
if (mainWindow) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
|
||||
app.on("open-url", (event: Event, url: string) => {
|
||||
event.preventDefault()
|
||||
logger.log("deep link received via open-url", { url })
|
||||
emitDeepLinks([url])
|
||||
})
|
||||
|
||||
app.on("before-quit", () => {
|
||||
void killSidecar()
|
||||
})
|
||||
|
||||
app.on("will-quit", () => {
|
||||
void killSidecar()
|
||||
})
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => {
|
||||
void killSidecar().finally(() => app.exit(0))
|
||||
})
|
||||
}
|
||||
|
||||
const serverReady = Deferred.makeUnsafe<ServerReadyData>()
|
||||
const loadingComplete = Deferred.makeUnsafe<void>()
|
||||
|
||||
registerIpcHandlers({
|
||||
killSidecar: () => killSidecar(),
|
||||
awaitInitialization: Effect.fnUntraced(
|
||||
function* (sendStep) {
|
||||
sendStep(initStep)
|
||||
const listener = (step: InitStep) => sendStep(step)
|
||||
initEmitter.on("step", listener)
|
||||
try {
|
||||
logger.log("awaiting server ready")
|
||||
const res = yield* Deferred.await(serverReady)
|
||||
logger.log("server ready", { url: res.url })
|
||||
return res
|
||||
} finally {
|
||||
initEmitter.off("step", listener)
|
||||
}
|
||||
},
|
||||
(e) => Effect.runPromise(e),
|
||||
),
|
||||
getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
|
||||
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
|
||||
getDefaultServerUrl: () => getDefaultServerUrl(),
|
||||
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
|
||||
getWslConfig: () => Promise.resolve(getWslConfig()),
|
||||
setWslConfig: (config: WslConfig) => setWslConfig(config),
|
||||
getDisplayBackend: async () => null,
|
||||
setDisplayBackend: async () => undefined,
|
||||
parseMarkdown: async (markdown) => parseMarkdown(markdown),
|
||||
checkAppExists: (appName) => checkAppExists(appName),
|
||||
wslPath: async (path, mode) => wslPath(path, mode),
|
||||
resolveAppPath: async (appName) => resolveAppPath(appName),
|
||||
loadingWindowComplete: () => Deferred.doneUnsafe(loadingComplete, Effect.void),
|
||||
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, killSidecar),
|
||||
checkUpdate: async () => checkUpdate(),
|
||||
installUpdate: async () => installUpdate(killSidecar),
|
||||
setBackgroundColor: (color) => setBackgroundColor(color),
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => app.whenReady())
|
||||
|
||||
if (!TEST_ONBOARDING) migrate()
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
registerRendererProtocol()
|
||||
setDockIcon()
|
||||
setupAutoUpdater()
|
||||
|
||||
const needsMigration = ((): boolean => {
|
||||
if (process.env.OPENCODE_DB === ":memory:") return false
|
||||
|
||||
const xdg = process.env.XDG_DATA_HOME
|
||||
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share")
|
||||
return !existsSync(join(base, "opencode", "opencode.db"))
|
||||
})()
|
||||
let overlay: BrowserWindow | null = null
|
||||
|
||||
const port = yield* Effect.gen(function* () {
|
||||
const fromEnv = process.env.OPENCODE_PORT
|
||||
if (fromEnv) {
|
||||
const parsed = Number.parseInt(fromEnv, 10)
|
||||
if (!Number.isNaN(parsed)) return parsed
|
||||
}
|
||||
|
||||
const res = yield* Deferred.make<number, unknown>()
|
||||
const server = createServer()
|
||||
server.on("error", reject)
|
||||
server.on("error", (e) => Deferred.failSync(res, () => e))
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address()
|
||||
if (typeof address !== "object" || !address) {
|
||||
server.close()
|
||||
reject(new Error("Failed to get port"))
|
||||
Deferred.failSync(res, () => new Error("Failed to get port"))
|
||||
return
|
||||
}
|
||||
const port = address.port
|
||||
server.close(() => resolve(port))
|
||||
server.close(() => Effect.runSync(Deferred.succeed(res, port)))
|
||||
})
|
||||
|
||||
return yield* Deferred.await(res)
|
||||
})
|
||||
}
|
||||
const hostname = "127.0.0.1"
|
||||
const url = `http://${hostname}:${port}`
|
||||
const password = randomUUID()
|
||||
|
||||
function sqliteFileExists() {
|
||||
if (process.env.OPENCODE_DB === ":memory:") return true
|
||||
const loadingTask = yield* Effect.gen(function* () {
|
||||
logger.log("sidecar connection started", { url })
|
||||
|
||||
const xdg = process.env.XDG_DATA_HOME
|
||||
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share")
|
||||
return existsSync(join(base, "opencode", "opencode.db"))
|
||||
}
|
||||
|
||||
function setupAutoUpdater() {
|
||||
if (!UPDATER_ENABLED) return
|
||||
autoUpdater.logger = logger
|
||||
autoUpdater.channel = "latest"
|
||||
autoUpdater.allowPrerelease = false
|
||||
autoUpdater.allowDowngrade = true
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = false
|
||||
logger.log("auto updater configured", {
|
||||
channel: autoUpdater.channel,
|
||||
allowPrerelease: autoUpdater.allowPrerelease,
|
||||
allowDowngrade: autoUpdater.allowDowngrade,
|
||||
currentVersion: app.getVersion(),
|
||||
})
|
||||
}
|
||||
|
||||
let downloadedUpdateVersion: string | undefined
|
||||
|
||||
async function checkUpdate() {
|
||||
if (!UPDATER_ENABLED) return { updateAvailable: false }
|
||||
if (downloadedUpdateVersion) {
|
||||
logger.log("returning cached downloaded update", {
|
||||
version: downloadedUpdateVersion,
|
||||
initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => {
|
||||
setInitStep({ phase: "sqlite_waiting" })
|
||||
if (overlay) sendSqliteMigrationProgress(overlay, progress)
|
||||
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
|
||||
})
|
||||
return { updateAvailable: true, version: downloadedUpdateVersion }
|
||||
}
|
||||
logger.log("checking for updates", {
|
||||
currentVersion: app.getVersion(),
|
||||
channel: autoUpdater.channel,
|
||||
allowPrerelease: autoUpdater.allowPrerelease,
|
||||
allowDowngrade: autoUpdater.allowDowngrade,
|
||||
})
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates()
|
||||
const updateInfo = result?.updateInfo
|
||||
logger.log("update metadata fetched", {
|
||||
releaseVersion: updateInfo?.version ?? null,
|
||||
releaseDate: updateInfo?.releaseDate ?? null,
|
||||
releaseName: updateInfo?.releaseName ?? null,
|
||||
files: updateInfo?.files?.map((file) => file.url) ?? [],
|
||||
|
||||
logger.log("spawning sidecar", { url })
|
||||
const { listener, health } = yield* Effect.promise(() =>
|
||||
spawnLocalServer(
|
||||
hostname,
|
||||
port,
|
||||
password,
|
||||
() => {
|
||||
ensureLoopbackNoProxy()
|
||||
useEnvProxy()
|
||||
},
|
||||
{
|
||||
needsMigration,
|
||||
userDataPath: app.getPath("userData"),
|
||||
onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress),
|
||||
onStdout: (message) => logger.log("sidecar stdout", { message }),
|
||||
onStderr: (message) => logger.warn("sidecar stderr", { message }),
|
||||
onExit: (code) => logger.warn("sidecar exited", { code }),
|
||||
},
|
||||
),
|
||||
)
|
||||
server = listener
|
||||
yield* Deferred.succeed(serverReady, {
|
||||
url,
|
||||
username: "opencode",
|
||||
password,
|
||||
})
|
||||
const version = result?.updateInfo?.version
|
||||
if (result?.isUpdateAvailable === false || !version) {
|
||||
logger.log("no update available", {
|
||||
reason: "provider returned no newer version",
|
||||
})
|
||||
return { updateAvailable: false }
|
||||
|
||||
yield* Effect.promise(() => health.wait).pipe(
|
||||
Effect.timeout("30 seconds"),
|
||||
Effect.catch((e) =>
|
||||
Effect.sync(() => {
|
||||
logger.error("sidecar health check failed", e.toString())
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
logger.log("loading task finished")
|
||||
}).pipe(Effect.forkChild)
|
||||
|
||||
if (needsMigration) {
|
||||
const show = yield* loadingTask.pipe(
|
||||
Fiber.await,
|
||||
Effect.timeout("1 second"),
|
||||
Effect.as(false),
|
||||
Effect.catch(() => Effect.succeed(true)),
|
||||
)
|
||||
if (show) {
|
||||
overlay = createLoadingWindow()
|
||||
yield* Effect.sleep("1 second")
|
||||
}
|
||||
logger.log("update available", { version })
|
||||
await autoUpdater.downloadUpdate()
|
||||
logger.log("update download completed", { version })
|
||||
downloadedUpdateVersion = version
|
||||
return { updateAvailable: true, version }
|
||||
} catch (error) {
|
||||
logger.error("update check failed", error)
|
||||
return { updateAvailable: false, failed: true }
|
||||
}
|
||||
}
|
||||
|
||||
async function installUpdate() {
|
||||
if (!downloadedUpdateVersion) {
|
||||
logger.log("install update skipped", {
|
||||
reason: "no downloaded update ready",
|
||||
yield* Fiber.await(loadingTask)
|
||||
setInitStep({ phase: "done" })
|
||||
|
||||
if (overlay) yield* Deferred.await(loadingComplete)
|
||||
|
||||
mainWindow = createMainWindow()
|
||||
if (mainWindow) {
|
||||
createMenu({
|
||||
trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
|
||||
checkForUpdates: () => {
|
||||
void checkForUpdates(true, killSidecar)
|
||||
},
|
||||
reload: () => mainWindow?.reload(),
|
||||
relaunch: () => {
|
||||
void killSidecar().finally(() => {
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
})
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
logger.log("installing downloaded update", {
|
||||
version: downloadedUpdateVersion,
|
||||
})
|
||||
await killSidecar()
|
||||
autoUpdater.quitAndInstall(true, true)
|
||||
}
|
||||
|
||||
async function checkForUpdates(alertOnFail: boolean) {
|
||||
if (!UPDATER_ENABLED) return
|
||||
logger.log("checkForUpdates invoked", { alertOnFail })
|
||||
const result = await checkUpdate()
|
||||
if (!result.updateAvailable) {
|
||||
if (result.failed) {
|
||||
logger.log("no update decision", { reason: "update check failed" })
|
||||
if (!alertOnFail) return
|
||||
await dialog.showMessageBox({
|
||||
type: "error",
|
||||
message: "Update check failed.",
|
||||
title: "Update Error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger.log("no update decision", { reason: "already up to date" })
|
||||
if (!alertOnFail) return
|
||||
await dialog.showMessageBox({
|
||||
type: "info",
|
||||
message: "You're up to date.",
|
||||
title: "No Updates",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const response = await dialog.showMessageBox({
|
||||
type: "info",
|
||||
message: `Update ${result.version ?? ""} downloaded. Restart now?`,
|
||||
title: "Update Ready",
|
||||
buttons: ["Restart", "Later"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
})
|
||||
logger.log("update prompt response", {
|
||||
version: result.version ?? null,
|
||||
restartNow: response.response === 0,
|
||||
})
|
||||
if (response.response === 0) {
|
||||
await installUpdate()
|
||||
}
|
||||
}
|
||||
overlay?.close()
|
||||
})
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function defer<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
Effect.runFork(main)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MainLogger } from "electron-log"
|
||||
import log from "electron-log/main.js"
|
||||
import { readFileSync, readdirSync, statSync, unlinkSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
@@ -5,11 +6,14 @@ import { dirname, join } from "node:path"
|
||||
const MAX_LOG_AGE_DAYS = 7
|
||||
const TAIL_LINES = 1000
|
||||
|
||||
let logger: MainLogger
|
||||
export const getLogger = () => logger
|
||||
|
||||
export function initLogging() {
|
||||
log.transports.file.maxSize = 5 * 1024 * 1024
|
||||
initConsoleTransport()
|
||||
cleanup()
|
||||
return log
|
||||
return (logger = log)
|
||||
}
|
||||
|
||||
export function tail(): string {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url"
|
||||
import { app, utilityProcess } from "electron"
|
||||
import type { Details } from "electron"
|
||||
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
||||
import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env"
|
||||
import { getUserShell, loadShellEnv } from "./shell-env"
|
||||
import { getStore } from "./store"
|
||||
import type { SqliteMigrationProgress } from "../preload/types"
|
||||
|
||||
@@ -57,16 +57,13 @@ export function setWslConfig(config: WslConfig) {
|
||||
|
||||
export function preferAppEnv(userDataPath: string) {
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
Object.assign(
|
||||
process.env,
|
||||
mergeShellEnv(shell ? loadShellEnv(shell) : null, {
|
||||
...process.env,
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
|
||||
}),
|
||||
)
|
||||
Object.assign(process.env, {
|
||||
...(shell ? loadShellEnv(shell) : null),
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
|
||||
})
|
||||
}
|
||||
|
||||
export async function spawnLocalServer(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { basename } from "node:path"
|
||||
import { getLogger } from "./logging"
|
||||
|
||||
const TIMEOUT = 5_000
|
||||
|
||||
@@ -55,28 +56,29 @@ export function isNushell(shell: string) {
|
||||
}
|
||||
|
||||
export function loadShellEnv(shell: string) {
|
||||
const logger = getLogger()
|
||||
if (isNushell(shell)) {
|
||||
console.log(`[server] Skipping shell env probe for nushell: ${shell}`)
|
||||
logger.log(`[server] Skipping shell env probe for nushell: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const interactive = probe(shell, "-il")
|
||||
if (interactive.type === "Loaded") {
|
||||
console.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
|
||||
logger.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
|
||||
return interactive.value
|
||||
}
|
||||
if (interactive.type === "Timeout") {
|
||||
console.warn(`[server] Interactive shell env probe timed out: ${shell}`)
|
||||
logger.log(`[server] Interactive shell env probe timed out: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const login = probe(shell, "-l")
|
||||
if (login.type === "Loaded") {
|
||||
console.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
|
||||
logger.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
|
||||
return login.value
|
||||
}
|
||||
|
||||
console.warn(`[server] Falling back to app environment: ${shell}`)
|
||||
logger.log(`[server] Falling back to app environment: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { app, dialog } from "electron"
|
||||
import pkg from "electron-updater"
|
||||
import { UPDATER_ENABLED } from "./constants"
|
||||
import { initLogging } from "./logging"
|
||||
|
||||
const logger = initLogging()
|
||||
const { autoUpdater } = pkg
|
||||
|
||||
let downloadedUpdateVersion: string | undefined
|
||||
|
||||
export function setupAutoUpdater() {
|
||||
if (!UPDATER_ENABLED) return
|
||||
autoUpdater.logger = logger
|
||||
autoUpdater.channel = "latest"
|
||||
autoUpdater.allowPrerelease = false
|
||||
autoUpdater.allowDowngrade = true
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = false
|
||||
logger.log("auto updater configured", {
|
||||
channel: autoUpdater.channel,
|
||||
allowPrerelease: autoUpdater.allowPrerelease,
|
||||
allowDowngrade: autoUpdater.allowDowngrade,
|
||||
currentVersion: app.getVersion(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function checkUpdate() {
|
||||
if (!UPDATER_ENABLED) return { updateAvailable: false }
|
||||
if (downloadedUpdateVersion) {
|
||||
logger.log("returning cached downloaded update", {
|
||||
version: downloadedUpdateVersion,
|
||||
})
|
||||
return { updateAvailable: true, version: downloadedUpdateVersion }
|
||||
}
|
||||
logger.log("checking for updates", {
|
||||
currentVersion: app.getVersion(),
|
||||
channel: autoUpdater.channel,
|
||||
allowPrerelease: autoUpdater.allowPrerelease,
|
||||
allowDowngrade: autoUpdater.allowDowngrade,
|
||||
})
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates()
|
||||
const updateInfo = result?.updateInfo
|
||||
logger.log("update metadata fetched", {
|
||||
releaseVersion: updateInfo?.version ?? null,
|
||||
releaseDate: updateInfo?.releaseDate ?? null,
|
||||
releaseName: updateInfo?.releaseName ?? null,
|
||||
files: updateInfo?.files?.map((file) => file.url) ?? [],
|
||||
})
|
||||
const version = result?.updateInfo?.version
|
||||
if (result?.isUpdateAvailable === false || !version) {
|
||||
logger.log("no update available", {
|
||||
reason: "provider returned no newer version",
|
||||
})
|
||||
return { updateAvailable: false }
|
||||
}
|
||||
logger.log("update available", { version })
|
||||
await autoUpdater.downloadUpdate()
|
||||
logger.log("update download completed", { version })
|
||||
downloadedUpdateVersion = version
|
||||
return { updateAvailable: true, version }
|
||||
} catch (error) {
|
||||
logger.error("update check failed", error)
|
||||
return { updateAvailable: false, failed: true }
|
||||
}
|
||||
}
|
||||
|
||||
export async function installUpdate(killSidecar: () => Promise<void>) {
|
||||
if (!downloadedUpdateVersion) {
|
||||
logger.log("install update skipped", {
|
||||
reason: "no downloaded update ready",
|
||||
})
|
||||
return
|
||||
}
|
||||
logger.log("installing downloaded update", {
|
||||
version: downloadedUpdateVersion,
|
||||
})
|
||||
await killSidecar()
|
||||
autoUpdater.quitAndInstall()
|
||||
}
|
||||
|
||||
export async function checkForUpdates(alertOnFail: boolean, killSidecar: () => Promise<void>) {
|
||||
if (!UPDATER_ENABLED) return
|
||||
logger.log("checkForUpdates invoked", { alertOnFail })
|
||||
const result = await checkUpdate()
|
||||
if (!result.updateAvailable) {
|
||||
if (result.failed) {
|
||||
logger.log("no update decision", { reason: "update check failed" })
|
||||
if (!alertOnFail) return
|
||||
await dialog.showMessageBox({
|
||||
type: "error",
|
||||
message: "Update check failed.",
|
||||
title: "Update Error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger.log("no update decision", { reason: "already up to date" })
|
||||
if (!alertOnFail) return
|
||||
await dialog.showMessageBox({
|
||||
type: "info",
|
||||
message: "You're up to date.",
|
||||
title: "No Updates",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const response = await dialog.showMessageBox({
|
||||
type: "info",
|
||||
message: `Update ${result.version ?? ""} downloaded. Restart now?`,
|
||||
title: "Update Ready",
|
||||
buttons: ["Restart", "Later"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
})
|
||||
logger.log("update prompt response", {
|
||||
version: result.version ?? null,
|
||||
restartNow: response.response === 0,
|
||||
})
|
||||
if (response.response === 0) {
|
||||
await installUpdate(killSidecar)
|
||||
}
|
||||
}
|
||||
@@ -135,9 +135,16 @@ function normalizeMessages(
|
||||
}
|
||||
if (!Array.isArray(msg.content)) return msg
|
||||
const filtered = msg.content.filter((part) => {
|
||||
if (part.type === "text" || part.type === "reasoning") {
|
||||
if (part.type === "text") {
|
||||
return part.text !== ""
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
return (
|
||||
part.text.trim().length > 0 ||
|
||||
part.providerOptions?.anthropic?.signature != null ||
|
||||
part.providerOptions?.anthropic?.redactedData != null
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (filtered.length === 0) return undefined
|
||||
@@ -156,9 +163,16 @@ function normalizeMessages(
|
||||
}
|
||||
if (!Array.isArray(msg.content)) return msg
|
||||
const filtered = msg.content.filter((part) => {
|
||||
if (part.type === "text" || part.type === "reasoning") {
|
||||
if (part.type === "text") {
|
||||
return part.text !== ""
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
return (
|
||||
part.text.trim().length > 0 ||
|
||||
part.providerOptions?.bedrock?.signature != null ||
|
||||
part.providerOptions?.bedrock?.redactedData != null
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (filtered.length === 0) return undefined
|
||||
|
||||
@@ -35,7 +35,7 @@ interface FetchDecompressionError extends Error {
|
||||
path: string
|
||||
}
|
||||
|
||||
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:"
|
||||
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:"
|
||||
export { isMedia }
|
||||
|
||||
export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
|
||||
@@ -734,25 +734,25 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
|
||||
const result: UIMessage[] = []
|
||||
const toolNames = new Set<string>()
|
||||
// Track media from tool results that need to be injected as user messages
|
||||
// for providers that don't support media in tool results.
|
||||
// for providers that don't support that media type in tool results.
|
||||
//
|
||||
// OpenAI-compatible APIs only support string content in tool results, so we need
|
||||
// to extract media and inject as user messages. Other SDKs (anthropic, google,
|
||||
// bedrock) handle type: "content" with media parts natively.
|
||||
// to extract media and inject as user messages. Some SDKs only support a subset
|
||||
// of media in tool results; e.g. Bedrock supports images but not PDFs there.
|
||||
//
|
||||
// Only apply this workaround if the model actually supports image input -
|
||||
// otherwise there's no point extracting images.
|
||||
const supportsMediaInToolResults = (() => {
|
||||
// Only apply this workaround if the model actually supports that media input -
|
||||
// otherwise unsupportedParts() will turn it into a user-visible error.
|
||||
const supportsMediaInToolResult = (attachment: { mime: string }) => {
|
||||
if (model.api.npm === "@ai-sdk/anthropic") return true
|
||||
if (model.api.npm === "@ai-sdk/openai") return true
|
||||
if (model.api.npm === "@ai-sdk/amazon-bedrock") return true
|
||||
if (model.api.npm === "@ai-sdk/amazon-bedrock") return attachment.mime.startsWith("image/")
|
||||
if (model.api.npm === "@ai-sdk/google-vertex/anthropic") return true
|
||||
if (model.api.npm === "@ai-sdk/google") {
|
||||
const id = model.api.id.toLowerCase()
|
||||
return id.includes("gemini-3") && !id.includes("gemini-2")
|
||||
}
|
||||
return false
|
||||
})()
|
||||
}
|
||||
|
||||
const toModelOutput = (options: { toolCallId: string; input: unknown; output: unknown }) => {
|
||||
const output = options.output
|
||||
@@ -797,9 +797,9 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
|
||||
role: "user",
|
||||
parts: [],
|
||||
}
|
||||
result.push(userMessage)
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "text" && !part.ignored)
|
||||
// User message parts should never be empty
|
||||
if (part.type === "text" && !part.ignored && part.text !== "")
|
||||
userMessage.parts.push({
|
||||
type: "text",
|
||||
text: part.text,
|
||||
@@ -834,11 +834,12 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
|
||||
})
|
||||
}
|
||||
}
|
||||
if (userMessage.parts.length > 0) result.push(userMessage)
|
||||
}
|
||||
|
||||
if (msg.info.role === "assistant") {
|
||||
const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}`
|
||||
const media: Array<{ mime: string; url: string }> = []
|
||||
const media: Array<{ mime: string; url: string; filename?: string }> = []
|
||||
|
||||
if (
|
||||
msg.info.error &&
|
||||
@@ -864,11 +865,10 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
|
||||
// a proxy, or a lower-level library, but preserving a non-empty separator
|
||||
// here is the only safe replay point we have.
|
||||
// Use a single space so the separator survives replay without changing
|
||||
// the neighboring signed reasoning blocks. Bedrock-hosted Claude stores
|
||||
// the same signature under the bedrock metadata namespace.
|
||||
// the neighboring signed reasoning blocks.
|
||||
const hasSignedReasoning = msg.parts.some((part) => {
|
||||
if (part.type !== "reasoning") return false
|
||||
return part.metadata?.anthropic?.signature != null || part.metadata?.bedrock?.signature != null
|
||||
return part.metadata?.anthropic?.signature != null
|
||||
})
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "text") {
|
||||
@@ -894,11 +894,11 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
|
||||
// For providers that don't support media in tool results, extract media files
|
||||
// (images, PDFs) to be sent as a separate user message
|
||||
const mediaAttachments = attachments.filter((a) => isMedia(a.mime))
|
||||
const nonMediaAttachments = attachments.filter((a) => !isMedia(a.mime))
|
||||
if (!supportsMediaInToolResults && mediaAttachments.length > 0) {
|
||||
media.push(...mediaAttachments)
|
||||
const extractedMedia = mediaAttachments.filter((a) => !supportsMediaInToolResult(a))
|
||||
if (extractedMedia.length > 0) {
|
||||
media.push(...extractedMedia)
|
||||
}
|
||||
const finalAttachments = supportsMediaInToolResults ? attachments : nonMediaAttachments
|
||||
const finalAttachments = attachments.filter((a) => !isMedia(a.mime) || supportsMediaInToolResult(a))
|
||||
|
||||
const output =
|
||||
finalAttachments.length > 0
|
||||
@@ -988,6 +988,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
|
||||
type: "file" as const,
|
||||
url: attachment.url,
|
||||
mediaType: attachment.mime,
|
||||
filename: attachment.filename,
|
||||
})),
|
||||
],
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import { iife } from "@/util/iife"
|
||||
export type Err = ReturnType<NamedError["toObject"]>
|
||||
|
||||
export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go"
|
||||
export const PAYG_UPSELL_MESSAGE = "Go usage exceeded, enable PAYG"
|
||||
export const GO_UPSELL_URL = "https://opencode.ai/go"
|
||||
|
||||
export type Retryable = {
|
||||
@@ -83,11 +82,11 @@ export function retryable(error: Err) {
|
||||
if (error.data.responseBody?.includes("GoUsageLimitError")) {
|
||||
const body = parseJSON(error.data.responseBody)
|
||||
const workspace = str(body?.metadata?.workspace)
|
||||
const limit = str(body?.metadata?.limit)
|
||||
const resetAt = num(body?.metadata?.resetAt)
|
||||
const limitName = str(body?.metadata?.limitName)
|
||||
const retryAfter = num(error.data.responseHeaders?.["retry-after"])
|
||||
const resetIn = iife(() => {
|
||||
if (resetAt === undefined) return ""
|
||||
const seconds = Math.max(0, Math.ceil(resetAt))
|
||||
if (retryAfter === undefined) return ""
|
||||
const seconds = Math.max(0, Math.ceil(retryAfter))
|
||||
const days = Math.floor(seconds / 86_400)
|
||||
const hours = Math.floor((seconds % 86_400) / 3_600)
|
||||
const minutes = Math.ceil((seconds % 3_600) / 60)
|
||||
@@ -97,16 +96,17 @@ export function retryable(error: Err) {
|
||||
if (hours > 0) return minutes > 0 ? `${unit(hours, "hour")} ${unit(minutes, "minute")}` : unit(hours, "hour")
|
||||
return minutes > 0 ? unit(minutes, "minute") : "less than a minute"
|
||||
})
|
||||
|
||||
const message = `${limitName} usage limit reached. It will reset in ${resetIn}. To continue using this model now, enable usage from your available balance`
|
||||
|
||||
const link = `https://opencode.ai/workspace/${workspace}/go`
|
||||
return {
|
||||
message: PAYG_UPSELL_MESSAGE,
|
||||
message: `${message} - ${link}`,
|
||||
action: {
|
||||
title: "Go limit reached",
|
||||
message:
|
||||
limit && resetIn
|
||||
? `You hit your ${limit} limit. It will reset in ${resetIn}. You can also enable pay-as-you-go.`
|
||||
: "Enable pay-as-you-go to keep using Go models after your subscription quota is used.",
|
||||
label: "enable PAYG",
|
||||
...(workspace ? { link: `https://opencode.ai/workspace/${workspace}/go` } : {}),
|
||||
message,
|
||||
label: "open settings",
|
||||
link,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,54 @@ describe("session.message-v2.toModelMessage", () => {
|
||||
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([])
|
||||
})
|
||||
|
||||
test("filters out user messages with only empty text parts", async () => {
|
||||
const messageID = "m-user"
|
||||
|
||||
const input: MessageV2.WithParts[] = [
|
||||
{
|
||||
info: userInfo(messageID),
|
||||
parts: [
|
||||
{
|
||||
...basePart(messageID, "p1"),
|
||||
type: "text",
|
||||
text: "",
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
]
|
||||
|
||||
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([])
|
||||
})
|
||||
|
||||
test("filters empty user text parts while keeping non-empty parts", async () => {
|
||||
const messageID = "m-user"
|
||||
|
||||
const input: MessageV2.WithParts[] = [
|
||||
{
|
||||
info: userInfo(messageID),
|
||||
parts: [
|
||||
{
|
||||
...basePart(messageID, "p1"),
|
||||
type: "text",
|
||||
text: "",
|
||||
},
|
||||
{
|
||||
...basePart(messageID, "p2"),
|
||||
type: "text",
|
||||
text: "hello",
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
]
|
||||
|
||||
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("includes synthetic text parts", async () => {
|
||||
const messageID = "m-user"
|
||||
|
||||
@@ -443,6 +491,113 @@ describe("session.message-v2.toModelMessage", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("moves bedrock pdf tool-result media into a separate user message", async () => {
|
||||
const bedrockModel: Provider.Model = {
|
||||
...model,
|
||||
id: ModelID.make("amazon-bedrock/anthropic.claude-sonnet-4-6"),
|
||||
providerID: ProviderID.make("amazon-bedrock"),
|
||||
api: {
|
||||
id: "anthropic.claude-sonnet-4-6",
|
||||
url: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
npm: "@ai-sdk/amazon-bedrock",
|
||||
},
|
||||
capabilities: {
|
||||
...model.capabilities,
|
||||
attachment: true,
|
||||
input: {
|
||||
...model.capabilities.input,
|
||||
image: true,
|
||||
pdf: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
const pdf = Buffer.from("%PDF-1.4\n").toString("base64")
|
||||
const userID = "m-user-bedrock-pdf"
|
||||
const assistantID = "m-assistant-bedrock-pdf"
|
||||
const input: MessageV2.WithParts[] = [
|
||||
{
|
||||
info: userInfo(userID),
|
||||
parts: [
|
||||
{
|
||||
...basePart(userID, "u1-bedrock-pdf"),
|
||||
type: "text",
|
||||
text: "run tool",
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
{
|
||||
info: assistantInfo(assistantID, userID),
|
||||
parts: [
|
||||
{
|
||||
...basePart(assistantID, "a1-bedrock-pdf"),
|
||||
type: "tool",
|
||||
callID: "call-bedrock-pdf-1",
|
||||
tool: "read",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "/tmp/example.pdf" },
|
||||
output: "PDF read successfully",
|
||||
title: "Read",
|
||||
metadata: {},
|
||||
time: { start: 0, end: 1 },
|
||||
attachments: [
|
||||
{
|
||||
...basePart(assistantID, "file-bedrock-pdf-1"),
|
||||
type: "file",
|
||||
mime: "application/pdf",
|
||||
filename: "example.pdf",
|
||||
url: `data:application/pdf;base64,${pdf}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
] as MessageV2.Part[],
|
||||
},
|
||||
]
|
||||
|
||||
expect(await MessageV2.toModelMessages(input, bedrockModel)).toStrictEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "run tool" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call-bedrock-pdf-1",
|
||||
toolName: "read",
|
||||
input: { filePath: "/tmp/example.pdf" },
|
||||
providerExecuted: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call-bedrock-pdf-1",
|
||||
toolName: "read",
|
||||
output: { type: "text", value: "PDF read successfully" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Attached media from tool result:" },
|
||||
{
|
||||
type: "file",
|
||||
mediaType: "application/pdf",
|
||||
filename: "example.pdf",
|
||||
data: `data:application/pdf;base64,${pdf}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("omits provider metadata when assistant model differs", async () => {
|
||||
const userID = "m-user"
|
||||
const assistantID = "m-assistant"
|
||||
@@ -1134,8 +1289,9 @@ describe("session.message-v2.toModelMessage", () => {
|
||||
expect((result[1].content as any[]).find((p) => p.type === "text").text).toBe("the answer")
|
||||
})
|
||||
|
||||
test("substitutes space for empty text when reasoning signature is under 'bedrock' namespace", async () => {
|
||||
// AWS Bedrock hosts Anthropic Claude but stores signatures under metadata.bedrock
|
||||
test("leaves empty text alone when reasoning signature is under 'bedrock' namespace", async () => {
|
||||
// Bedrock signed reasoning is preserved as reasoning metadata, but unlike the
|
||||
// direct Anthropic path we do not preserve empty text separators for Bedrock.
|
||||
const assistantID = "m-assistant-bedrock"
|
||||
const input: MessageV2.WithParts[] = [
|
||||
{
|
||||
@@ -1157,7 +1313,7 @@ describe("session.message-v2.toModelMessage", () => {
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
const texts = (result[0].content as any[]).filter((p) => p.type === "text")
|
||||
expect(texts.map((t) => t.text)).toStrictEqual([" ", "answer"])
|
||||
expect(texts.map((t) => t.text)).toStrictEqual(["", "answer"])
|
||||
})
|
||||
|
||||
test("leaves empty text alone when reasoning has no Anthropic signature", async () => {
|
||||
|
||||
@@ -279,11 +279,13 @@ describe("session.retry.retryable", () => {
|
||||
)
|
||||
|
||||
expect(SessionRetry.retryable(error)).toEqual({
|
||||
message: SessionRetry.PAYG_UPSELL_MESSAGE,
|
||||
message:
|
||||
"5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance - https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go",
|
||||
action: {
|
||||
title: "Go limit reached",
|
||||
message: "You hit your 5 hour limit. It will reset in 5 hours 23 minutes. You can also enable pay-as-you-go.",
|
||||
label: "enable PAYG",
|
||||
message:
|
||||
"5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance",
|
||||
label: "open settings",
|
||||
link: "https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
|
||||
import { Show, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { TextShimmer } from "./text-shimmer"
|
||||
|
||||
@@ -15,10 +15,8 @@ function common(active: string, done: string) {
|
||||
}
|
||||
|
||||
function contentWidth(el: HTMLSpanElement | undefined) {
|
||||
if (!el) return 0
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(el)
|
||||
return Math.ceil(range.getBoundingClientRect().width)
|
||||
if (!el) return
|
||||
return `${Math.ceil(el.getBoundingClientRect().width)}px`
|
||||
}
|
||||
|
||||
export function ToolStatusTitle(props: {
|
||||
@@ -37,99 +35,99 @@ export function ToolStatusTitle(props: {
|
||||
const doneTail = createMemo(() => (suffix() ? split().done : props.doneText))
|
||||
|
||||
const [state, setState] = createStore({
|
||||
width: "auto",
|
||||
ready: false,
|
||||
active: props.active,
|
||||
animating: false,
|
||||
width: undefined as string | undefined,
|
||||
})
|
||||
const width = () => state.width
|
||||
const ready = () => state.ready
|
||||
const active = () => state.active
|
||||
const animating = () => state.animating
|
||||
let activeRef: HTMLSpanElement | undefined
|
||||
let doneRef: HTMLSpanElement | undefined
|
||||
let widthRef: HTMLSpanElement | undefined
|
||||
let frame: number | undefined
|
||||
let readyFrame: number | undefined
|
||||
|
||||
const measure = () => {
|
||||
const target = props.active ? activeRef : doneRef
|
||||
const px = contentWidth(target)
|
||||
if (px > 0) setState("width", `${px}px`)
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
if (typeof requestAnimationFrame !== "function") {
|
||||
measure()
|
||||
return
|
||||
}
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
measure()
|
||||
})
|
||||
}
|
||||
let finishTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const finish = () => {
|
||||
if (typeof requestAnimationFrame !== "function") {
|
||||
setState("ready", true)
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
if (finishTimer !== undefined) clearTimeout(finishTimer)
|
||||
frame = undefined
|
||||
finishTimer = undefined
|
||||
setState("animating", false)
|
||||
setState("width", undefined)
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
const first = contentWidth(widthRef)
|
||||
finish()
|
||||
setState("animating", true)
|
||||
setState("active", props.active)
|
||||
const last = contentWidth(props.active ? activeRef : doneRef)
|
||||
if (!first || !last) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
if (readyFrame !== undefined) cancelAnimationFrame(readyFrame)
|
||||
readyFrame = requestAnimationFrame(() => {
|
||||
readyFrame = undefined
|
||||
setState("ready", true)
|
||||
|
||||
setState("width", first)
|
||||
if (first === last) {
|
||||
finishTimer = setTimeout(finish, 600)
|
||||
return
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
setState("width", last)
|
||||
finishTimer = setTimeout(finish, 600)
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(on([() => props.active, activeTail, doneTail, suffix], () => schedule()))
|
||||
|
||||
onMount(() => {
|
||||
measure()
|
||||
const fonts = typeof document !== "undefined" ? document.fonts : undefined
|
||||
if (!fonts) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
void fonts.ready.finally(() => {
|
||||
measure()
|
||||
finish()
|
||||
})
|
||||
})
|
||||
createEffect(on([() => props.active, activeTail, doneTail], () => animate(), { defer: true }))
|
||||
|
||||
onCleanup(() => {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
if (readyFrame !== undefined) cancelAnimationFrame(readyFrame)
|
||||
finish()
|
||||
})
|
||||
|
||||
return (
|
||||
<span
|
||||
data-component="tool-status-title"
|
||||
data-active={props.active ? "true" : "false"}
|
||||
data-ready={ready() ? "true" : "false"}
|
||||
data-active={active() ? "true" : "false"}
|
||||
data-ready={animating() ? "true" : "false"}
|
||||
data-mode={suffix() ? "suffix" : "swap"}
|
||||
class={props.class}
|
||||
aria-label={props.active ? props.activeText : props.doneText}
|
||||
aria-label={active() ? props.activeText : props.doneText}
|
||||
>
|
||||
<Show
|
||||
when={suffix()}
|
||||
fallback={
|
||||
<span data-slot="tool-status-swap" style={{ width: width() }}>
|
||||
<span data-slot="tool-status-active" ref={activeRef}>
|
||||
<TextShimmer text={activeTail()} active={props.active} offset={0} />
|
||||
</span>
|
||||
<span data-slot="tool-status-done" ref={doneRef}>
|
||||
<TextShimmer text={doneTail()} active={false} offset={0} />
|
||||
</span>
|
||||
<span data-slot="tool-status-swap" ref={widthRef} style={{ width: width() }}>
|
||||
<Show when={animating() || active()}>
|
||||
<span data-slot="tool-status-active" ref={activeRef}>
|
||||
<TextShimmer text={activeTail()} active={active()} offset={0} />
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={animating() || !active()}>
|
||||
<span data-slot="tool-status-done" ref={doneRef}>
|
||||
<TextShimmer text={doneTail()} active={false} offset={0} />
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span data-slot="tool-status-suffix">
|
||||
<span data-slot="tool-status-prefix">
|
||||
<TextShimmer text={split().prefix} active={props.active} offset={0} />
|
||||
<TextShimmer text={split().prefix} active={active()} offset={0} />
|
||||
</span>
|
||||
<span data-slot="tool-status-tail" style={{ width: width() }}>
|
||||
<span data-slot="tool-status-active" ref={activeRef}>
|
||||
<TextShimmer text={activeTail()} active={props.active} offset={prefixLen()} />
|
||||
</span>
|
||||
<span data-slot="tool-status-done" ref={doneRef}>
|
||||
<TextShimmer text={doneTail()} active={false} offset={prefixLen()} />
|
||||
</span>
|
||||
<span data-slot="tool-status-tail" ref={widthRef} style={{ width: width() }}>
|
||||
<Show when={animating() || active()}>
|
||||
<span data-slot="tool-status-active" ref={activeRef}>
|
||||
<TextShimmer text={activeTail()} active={active()} offset={prefixLen()} />
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={animating() || !active()}>
|
||||
<span data-slot="tool-status-done" ref={doneRef}>
|
||||
<TextShimmer text={doneTail()} active={false} offset={prefixLen()} />
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
Reference in New Issue
Block a user