mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Fix duplicate rehydrate during reasoning; centralize rehydrate and preserve cancel metadata (#8171)
Co-authored-by: daniel-lxs <ricciodaniel98@gmail.com>
This commit is contained in:
parent
d6cb396271
commit
d956cdb727
20 changed files with 153 additions and 36 deletions
|
|
@ -212,6 +212,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
|
||||
didFinishAbortingStream = false
|
||||
abandoned = false
|
||||
abortReason?: ClineApiReqCancelReason
|
||||
isInitialized = false
|
||||
isPaused: boolean = false
|
||||
pausedModeSlug: string = defaultModeSlug
|
||||
|
|
@ -1264,6 +1265,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
modifiedClineMessages.splice(lastRelevantMessageIndex + 1)
|
||||
}
|
||||
|
||||
// Remove any trailing reasoning-only UI messages that were not part of the persisted API conversation
|
||||
while (modifiedClineMessages.length > 0) {
|
||||
const last = modifiedClineMessages[modifiedClineMessages.length - 1]
|
||||
if (last.type === "say" && last.say === "reasoning") {
|
||||
modifiedClineMessages.pop()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Since we don't use `api_req_finished` anymore, we need to check if the
|
||||
// last `api_req_started` has a cost value, if it doesn't and no
|
||||
// cancellation reason to present, then we remove it since it indicates
|
||||
|
|
@ -1884,28 +1895,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
lastMessage.partial = false
|
||||
// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
|
||||
console.log("updating partial message", lastMessage)
|
||||
// await this.saveClineMessages()
|
||||
}
|
||||
|
||||
// Let assistant know their response was interrupted for when task is resumed
|
||||
await this.addToApiConversationHistory({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
assistantMessage +
|
||||
`\n\n[${
|
||||
cancelReason === "streaming_failed"
|
||||
? "Response interrupted by API Error"
|
||||
: "Response interrupted by user"
|
||||
}]`,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Update `api_req_started` to have cancelled and cost, so that
|
||||
// we can display the cost of the partial stream.
|
||||
// we can display the cost of the partial stream and the cancellation reason
|
||||
updateApiReqMsg(cancelReason, streamingFailedMessage)
|
||||
await this.saveClineMessages()
|
||||
|
||||
|
|
@ -2187,24 +2180,23 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
// may have executed), so we just resort to replicating a
|
||||
// cancel task.
|
||||
|
||||
// Check if this was a user-initiated cancellation BEFORE calling abortTask
|
||||
// If this.abort is already true, it means the user clicked cancel, so we should
|
||||
// treat this as "user_cancelled" rather than "streaming_failed"
|
||||
const cancelReason = this.abort ? "user_cancelled" : "streaming_failed"
|
||||
// Determine cancellation reason BEFORE aborting to ensure correct persistence
|
||||
const cancelReason: ClineApiReqCancelReason = this.abort ? "user_cancelled" : "streaming_failed"
|
||||
|
||||
const streamingFailedMessage = this.abort
|
||||
? undefined
|
||||
: (error.message ?? JSON.stringify(serializeError(error), null, 2))
|
||||
|
||||
// Now call abortTask after determining the cancel reason.
|
||||
await this.abortTask()
|
||||
// Persist interruption details first to both UI and API histories
|
||||
await abortStream(cancelReason, streamingFailedMessage)
|
||||
|
||||
const history = await provider?.getTaskWithId(this.taskId)
|
||||
// Record reason for provider to decide rehydration path
|
||||
this.abortReason = cancelReason
|
||||
|
||||
if (history) {
|
||||
await provider?.createTaskWithHistoryItem(history.historyItem)
|
||||
}
|
||||
// Now abort (emits TaskAborted which provider listens to)
|
||||
await this.abortTask()
|
||||
|
||||
// Do not rehydrate here; provider owns rehydration to avoid duplication races
|
||||
}
|
||||
} finally {
|
||||
this.isStreaming = false
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ import { Task } from "../task/Task"
|
|||
import { getSystemPromptFilePath } from "../prompts/sections/custom-system-prompt"
|
||||
|
||||
import { webviewMessageHandler } from "./webviewMessageHandler"
|
||||
import type { ClineMessage } from "@roo-code/types"
|
||||
import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-persistence"
|
||||
import { getNonce } from "./getNonce"
|
||||
import { getUri } from "./getUri"
|
||||
|
||||
|
|
@ -196,7 +198,35 @@ export class ClineProvider
|
|||
const onTaskStarted = () => this.emit(RooCodeEventName.TaskStarted, instance.taskId)
|
||||
const onTaskCompleted = (taskId: string, tokenUsage: any, toolUsage: any) =>
|
||||
this.emit(RooCodeEventName.TaskCompleted, taskId, tokenUsage, toolUsage)
|
||||
const onTaskAborted = () => this.emit(RooCodeEventName.TaskAborted, instance.taskId)
|
||||
const onTaskAborted = async () => {
|
||||
this.emit(RooCodeEventName.TaskAborted, instance.taskId)
|
||||
|
||||
try {
|
||||
// Only rehydrate on genuine streaming failures.
|
||||
// User-initiated cancels are handled by cancelTask().
|
||||
if (instance.abortReason === "streaming_failed") {
|
||||
// Defensive safeguard: if another path already replaced this instance, skip
|
||||
const current = this.getCurrentTask()
|
||||
if (current && current.instanceId !== instance.instanceId) {
|
||||
this.log(
|
||||
`[onTaskAborted] Skipping rehydrate: current instance ${current.instanceId} != aborted ${instance.instanceId}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const { historyItem } = await this.getTaskWithId(instance.taskId)
|
||||
const rootTask = instance.rootTask
|
||||
const parentTask = instance.parentTask
|
||||
await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask })
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(
|
||||
`[onTaskAborted] Failed to rehydrate after streaming failure: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
const onTaskFocused = () => this.emit(RooCodeEventName.TaskFocused, instance.taskId)
|
||||
const onTaskUnfocused = () => this.emit(RooCodeEventName.TaskUnfocused, instance.taskId)
|
||||
const onTaskActive = (taskId: string) => this.emit(RooCodeEventName.TaskActive, taskId)
|
||||
|
|
@ -2525,14 +2555,24 @@ export class ClineProvider
|
|||
|
||||
console.log(`[cancelTask] cancelling task ${task.taskId}.${task.instanceId}`)
|
||||
|
||||
const { historyItem } = await this.getTaskWithId(task.taskId)
|
||||
const { historyItem, uiMessagesFilePath } = await this.getTaskWithId(task.taskId)
|
||||
|
||||
// Preserve parent and root task information for history item.
|
||||
const rootTask = task.rootTask
|
||||
const parentTask = task.parentTask
|
||||
|
||||
// Mark this as a user-initiated cancellation so provider-only rehydration can occur
|
||||
task.abortReason = "user_cancelled"
|
||||
|
||||
// Capture the current instance to detect if rehydrate already occurred elsewhere
|
||||
const originalInstanceId = task.instanceId
|
||||
|
||||
// Begin abort (non-blocking)
|
||||
task.abortTask()
|
||||
|
||||
// Immediately mark the original instance as abandoned to prevent any residual activity
|
||||
task.abandoned = true
|
||||
|
||||
await pWaitFor(
|
||||
() =>
|
||||
this.getCurrentTask()! === undefined ||
|
||||
|
|
@ -2549,11 +2589,24 @@ export class ClineProvider
|
|||
console.error("Failed to abort task")
|
||||
})
|
||||
|
||||
if (this.getCurrentTask()) {
|
||||
// 'abandoned' will prevent this Cline instance from affecting
|
||||
// future Cline instances. This may happen if its hanging on a
|
||||
// streaming request.
|
||||
this.getCurrentTask()!.abandoned = true
|
||||
// Defensive safeguard: if current instance already changed, skip rehydrate
|
||||
const current = this.getCurrentTask()
|
||||
if (current && current.instanceId !== originalInstanceId) {
|
||||
this.log(
|
||||
`[cancelTask] Skipping rehydrate: current instance ${current.instanceId} != original ${originalInstanceId}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Final race check before rehydrate to avoid duplicate rehydration
|
||||
{
|
||||
const currentAfterCheck = this.getCurrentTask()
|
||||
if (currentAfterCheck && currentAfterCheck.instanceId !== originalInstanceId) {
|
||||
this.log(
|
||||
`[cancelTask] Skipping rehydrate after final check: current instance ${currentAfterCheck.instanceId} != original ${originalInstanceId}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Clears task again, so we need to abortTask manually above.
|
||||
|
|
|
|||
4
src/i18n/locales/ca/common.json
generated
4
src/i18n/locales/ca/common.json
generated
|
|
@ -165,6 +165,10 @@
|
|||
"incomplete": "Tasca #{{taskNumber}} (Incompleta)",
|
||||
"no_messages": "Tasca #{{taskNumber}} (Sense missatges)"
|
||||
},
|
||||
"interruption": {
|
||||
"responseInterruptedByUser": "Resposta interrompuda per l'usuari",
|
||||
"responseInterruptedByApiError": "Resposta interrompuda per error d'API"
|
||||
},
|
||||
"storage": {
|
||||
"prompt_custom_path": "Introdueix una ruta d'emmagatzematge personalitzada per a l'historial de converses o deixa-ho buit per utilitzar la ubicació predeterminada",
|
||||
"path_placeholder": "D:\\RooCodeStorage",
|
||||
|
|
|
|||
4
src/i18n/locales/de/common.json
generated
4
src/i18n/locales/de/common.json
generated
|
|
@ -161,6 +161,10 @@
|
|||
"incomplete": "Aufgabe #{{taskNumber}} (Unvollständig)",
|
||||
"no_messages": "Aufgabe #{{taskNumber}} (Keine Nachrichten)"
|
||||
},
|
||||
"interruption": {
|
||||
"responseInterruptedByUser": "Antwort vom Benutzer unterbrochen",
|
||||
"responseInterruptedByApiError": "Antwort durch API-Fehler unterbrochen"
|
||||
},
|
||||
"storage": {
|
||||
"prompt_custom_path": "Gib den benutzerdefinierten Speicherpfad für den Gesprächsverlauf ein, leer lassen für Standardspeicherort",
|
||||
"path_placeholder": "D:\\RooCodeStorage",
|
||||
|
|
|
|||
|
|
@ -161,6 +161,10 @@
|
|||
"incomplete": "Task #{{taskNumber}} (Incomplete)",
|
||||
"no_messages": "Task #{{taskNumber}} (No messages)"
|
||||
},
|
||||
"interruption": {
|
||||
"responseInterruptedByUser": "Response interrupted by user",
|
||||
"responseInterruptedByApiError": "Response interrupted by API error"
|
||||
},
|
||||
"storage": {
|
||||
"prompt_custom_path": "Enter custom conversation history storage path, leave empty to use default location",
|
||||
"path_placeholder": "D:\\RooCodeStorage",
|
||||
|
|
|
|||
4
src/i18n/locales/es/common.json
generated
4
src/i18n/locales/es/common.json
generated
|
|
@ -161,6 +161,10 @@
|
|||
"incomplete": "Tarea #{{taskNumber}} (Incompleta)",
|
||||
"no_messages": "Tarea #{{taskNumber}} (Sin mensajes)"
|
||||
},
|
||||
"interruption": {
|
||||
"responseInterruptedByUser": "Respuesta interrumpida por el usuario",
|
||||
"responseInterruptedByApiError": "Respuesta interrumpida por error de API"
|
||||
},
|
||||
"storage": {
|
||||
"prompt_custom_path": "Ingresa la ruta de almacenamiento personalizada para el historial de conversaciones, déjala vacía para usar la ubicación predeterminada",
|
||||
"path_placeholder": "D:\\RooCodeStorage",
|
||||
|
|
|
|||
4
src/i18n/locales/fr/common.json
generated
4
src/i18n/locales/fr/common.json
generated
|
|
@ -161,6 +161,10 @@
|
|||
"incomplete": "Tâche #{{taskNumber}} (Incomplète)",
|
||||
"no_messages": "Tâche #{{taskNumber}} (Aucun message)"
|
||||
},
|
||||
"interruption": {
|
||||
"responseInterruptedByUser": "Réponse interrompue par l'utilisateur",
|
||||
"responseInterruptedByApiError": "Réponse interrompue par une erreur d'API"
|
||||
},
|
||||
"storage": {
|
||||
"prompt_custom_path": "Entrez le chemin de stockage personnalisé pour l'historique des conversations, laissez vide pour utiliser l'emplacement par défaut",
|
||||
"path_placeholder": "D:\\RooCodeStorage",
|
||||
|
|
|
|||
4
src/i18n/locales/hi/common.json
generated
4
src/i18n/locales/hi/common.json
generated
|
|
@ -161,6 +161,10 @@
|
|||
"incomplete": "टास्क #{{taskNumber}} (अधूरा)",
|
||||
"no_messages": "टास्क #{{taskNumber}} (कोई संदेश नहीं)"
|
||||
},
|
||||
"interruption": {
|
||||
"responseInterruptedByUser": "उपयोगकर्ता द्वारा प्रतिक्रिया बाधित",
|
||||
"responseInterruptedByApiError": "API त्रुटि द्वारा प्रतिक्रिया बाधित"
|
||||
},
|
||||
"storage": {
|
||||
"prompt_custom_path": "वार्तालाप इतिहास के लिए कस्टम स्टोरेज पाथ दर्ज करें, डिफ़ॉल्ट स्थान का उपयोग करने के लिए खाली छोड़ दें",
|
||||
"path_placeholder": "D:\\RooCodeStorage",
|
||||
|
|
|
|||
4
src/i18n/locales/id/common.json
generated
4
src/i18n/locales/id/common.json
generated
|
|
@ -161,6 +161,10 @@
|
|||
"incomplete": "Tugas #{{taskNumber}} (Tidak lengkap)",
|
||||
"no_messages": "Tugas #{{taskNumber}} (Tidak ada pesan)"
|
||||
},
|
||||
"interruption": {
|
||||
"responseInterruptedByUser": "Respons diinterupsi oleh pengguna",
|
||||
"responseInterruptedByApiError": "Respons diinterupsi oleh error API"
|
||||
},
|
||||
"storage": {
|
||||
"prompt_custom_path": "Masukkan path penyimpanan riwayat percakapan kustom, biarkan kosong untuk menggunakan lokasi default",
|
||||
"path_placeholder": "D:\\RooCodeStorage",
|
||||
|
|
|
|||
4
src/i18n/locales/it/common.json
generated
4
src/i18n/locales/it/common.json
generated
|
|
@ -161,6 +161,10 @@
|
|||
"incomplete": "Attività #{{taskNumber}} (Incompleta)",
|
||||
"no_messages": "Attività #{{taskNumber}} (Nessun messaggio)"
|
||||
},
|
||||
"interruption": {
|
||||
"responseInterruptedByUser": "Risposta interrotta dall'utente",
|
||||
"responseInterruptedByApiError": "Risposta interrotta da errore API"
|
||||
},
|
||||
"storage": {
|
||||
"prompt_custom_path": "Inserisci il percorso di archiviazione personalizzato per la cronologia delle conversazioni, lascia vuoto per utilizzare la posizione predefinita",
|
||||
"path_placeholder": "D:\\RooCodeStorage",
|
||||
|
|
|
|||
4
src/i18n/locales/ja/common.json
generated
4
src/i18n/locales/ja/common.json
generated
|
|
@ -161,6 +161,10 @@
|
|||
"incomplete": "タスク #{{taskNumber}} (未完了)",
|
||||
"no_messages": "タスク #{{taskNumber}} (メッセージなし)"
|
||||
},
|
||||
"interruption": {
|
||||
"responseInterruptedByUser": "ユーザーによって応答が中断されました",
|
||||
"responseInterruptedByApiError": "APIエラーによって応答が中断されました"
|
||||
},
|
||||
"storage": {
|
||||
"prompt_custom_path": "会話履歴のカスタムストレージパスを入力してください。デフォルトの場所を使用する場合は空のままにしてください",
|
||||
"path_placeholder": "D:\\RooCodeStorage",
|
||||
|
|
|
|||
4
src/i18n/locales/ko/common.json
generated
4
src/i18n/locales/ko/common.json
generated
|
|
@ -161,6 +161,10 @@
|
|||
"incomplete": "작업 #{{taskNumber}} (미완료)",
|
||||
"no_messages": "작업 #{{taskNumber}} (메시지 없음)"
|
||||
},
|
||||
"interruption": {
|
||||
"responseInterruptedByUser": "사용자에 의해 응답이 중단됨",
|
||||
"responseInterruptedByApiError": "API 오류로 인해 응답이 중단됨"
|
||||
},
|
||||
"storage": {
|
||||
"prompt_custom_path": "대화 내역을 위한 사용자 지정 저장 경로를 입력하세요. 기본 위치를 사용하려면 비워두세요",
|
||||
"path_placeholder": "D:\\RooCodeStorage",
|
||||
|
|
|
|||
4
src/i18n/locales/nl/common.json
generated
4
src/i18n/locales/nl/common.json
generated
|
|
@ -161,6 +161,10 @@
|
|||
"incomplete": "Taak #{{taskNumber}} (Onvolledig)",
|
||||
"no_messages": "Taak #{{taskNumber}} (Geen berichten)"
|
||||
},
|
||||
"interruption": {
|
||||
"responseInterruptedByUser": "Reactie onderbroken door gebruiker",
|
||||
"responseInterruptedByApiError": "Reactie onderbroken door API-fout"
|
||||
},
|
||||
"storage": {
|
||||
"prompt_custom_path": "Voer een aangepast opslagpad voor gespreksgeschiedenis in, laat leeg voor standaardlocatie",
|
||||
"path_placeholder": "D:\\RooCodeStorage",
|
||||
|
|
|
|||
4
src/i18n/locales/pl/common.json
generated
4
src/i18n/locales/pl/common.json
generated
|
|
@ -161,6 +161,10 @@
|
|||
"incomplete": "Zadanie #{{taskNumber}} (Niekompletne)",
|
||||
"no_messages": "Zadanie #{{taskNumber}} (Brak wiadomości)"
|
||||
},
|
||||
"interruption": {
|
||||
"responseInterruptedByUser": "Odpowiedź przerwana przez użytkownika",
|
||||
"responseInterruptedByApiError": "Odpowiedź przerwana przez błąd API"
|
||||
},
|
||||
"storage": {
|
||||
"prompt_custom_path": "Wprowadź niestandardową ścieżkę przechowywania dla historii konwersacji lub pozostaw puste, aby użyć lokalizacji domyślnej",
|
||||
"path_placeholder": "D:\\RooCodeStorage",
|
||||
|
|
|
|||
4
src/i18n/locales/pt-BR/common.json
generated
4
src/i18n/locales/pt-BR/common.json
generated
|
|
@ -165,6 +165,10 @@
|
|||
"incomplete": "Tarefa #{{taskNumber}} (Incompleta)",
|
||||
"no_messages": "Tarefa #{{taskNumber}} (Sem mensagens)"
|
||||
},
|
||||
"interruption": {
|
||||
"responseInterruptedByUser": "Resposta interrompida pelo usuário",
|
||||
"responseInterruptedByApiError": "Resposta interrompida por erro da API"
|
||||
},
|
||||
"storage": {
|
||||
"prompt_custom_path": "Digite o caminho de armazenamento personalizado para o histórico de conversas, deixe em branco para usar o local padrão",
|
||||
"path_placeholder": "D:\\RooCodeStorage",
|
||||
|
|
|
|||
4
src/i18n/locales/ru/common.json
generated
4
src/i18n/locales/ru/common.json
generated
|
|
@ -161,6 +161,10 @@
|
|||
"incomplete": "Задача #{{taskNumber}} (Незавершенная)",
|
||||
"no_messages": "Задача #{{taskNumber}} (Нет сообщений)"
|
||||
},
|
||||
"interruption": {
|
||||
"responseInterruptedByUser": "Ответ прерван пользователем",
|
||||
"responseInterruptedByApiError": "Ответ прерван ошибкой API"
|
||||
},
|
||||
"storage": {
|
||||
"prompt_custom_path": "Введите пользовательский путь хранения истории разговоров, оставьте пустым для использования расположения по умолчанию",
|
||||
"path_placeholder": "D:\\RooCodeStorage",
|
||||
|
|
|
|||
4
src/i18n/locales/tr/common.json
generated
4
src/i18n/locales/tr/common.json
generated
|
|
@ -161,6 +161,10 @@
|
|||
"incomplete": "Görev #{{taskNumber}} (Tamamlanmamış)",
|
||||
"no_messages": "Görev #{{taskNumber}} (Mesaj yok)"
|
||||
},
|
||||
"interruption": {
|
||||
"responseInterruptedByUser": "Yanıt kullanıcı tarafından kesildi",
|
||||
"responseInterruptedByApiError": "Yanıt API hatası nedeniyle kesildi"
|
||||
},
|
||||
"storage": {
|
||||
"prompt_custom_path": "Konuşma geçmişi için özel depolama yolunu girin, varsayılan konumu kullanmak için boş bırakın",
|
||||
"path_placeholder": "D:\\RooCodeStorage",
|
||||
|
|
|
|||
4
src/i18n/locales/vi/common.json
generated
4
src/i18n/locales/vi/common.json
generated
|
|
@ -161,6 +161,10 @@
|
|||
"incomplete": "Nhiệm vụ #{{taskNumber}} (Chưa hoàn thành)",
|
||||
"no_messages": "Nhiệm vụ #{{taskNumber}} (Không có tin nhắn)"
|
||||
},
|
||||
"interruption": {
|
||||
"responseInterruptedByUser": "Phản hồi bị gián đoạn bởi người dùng",
|
||||
"responseInterruptedByApiError": "Phản hồi bị gián đoạn bởi lỗi API"
|
||||
},
|
||||
"storage": {
|
||||
"prompt_custom_path": "Nhập đường dẫn lưu trữ tùy chỉnh cho lịch sử hội thoại, để trống để sử dụng vị trí mặc định",
|
||||
"path_placeholder": "D:\\RooCodeStorage",
|
||||
|
|
|
|||
4
src/i18n/locales/zh-CN/common.json
generated
4
src/i18n/locales/zh-CN/common.json
generated
|
|
@ -166,6 +166,10 @@
|
|||
"incomplete": "任务 #{{taskNumber}} (未完成)",
|
||||
"no_messages": "任务 #{{taskNumber}} (无消息)"
|
||||
},
|
||||
"interruption": {
|
||||
"responseInterruptedByUser": "响应被用户中断",
|
||||
"responseInterruptedByApiError": "响应被 API 错误中断"
|
||||
},
|
||||
"storage": {
|
||||
"prompt_custom_path": "输入自定义会话历史存储路径,留空以使用默认位置",
|
||||
"path_placeholder": "D:\\RooCodeStorage",
|
||||
|
|
|
|||
4
src/i18n/locales/zh-TW/common.json
generated
4
src/i18n/locales/zh-TW/common.json
generated
|
|
@ -161,6 +161,10 @@
|
|||
"incomplete": "工作 #{{taskNumber}} (未完成)",
|
||||
"no_messages": "工作 #{{taskNumber}} (無訊息)"
|
||||
},
|
||||
"interruption": {
|
||||
"responseInterruptedByUser": "回應被使用者中斷",
|
||||
"responseInterruptedByApiError": "回應被 API 錯誤中斷"
|
||||
},
|
||||
"storage": {
|
||||
"prompt_custom_path": "輸入自訂會話歷史儲存路徑,留空以使用預設位置",
|
||||
"path_placeholder": "D:\\RooCodeStorage",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue