| 1 | import { DiffMatchPatch, DOMPurify, localforage } from '../lib.js'; |
| 2 | import { chat, event_types, eventSource, getCurrentChatId, reloadCurrentChat } from '../script.js'; |
| 3 | import { t } from './i18n.js'; |
| 4 | import { oai_settings } from './openai.js'; |
| 5 | import { Popup, POPUP_TYPE } from './popup.js'; |
| 6 | import { power_user, registerDebugFunction } from './power-user.js'; |
| 7 | import { isMobile } from './RossAscends-mods.js'; |
| 8 | import { renderTemplateAsync } from './templates.js'; |
| 9 | import { getFriendlyTokenizerName, getTokenCountAsync } from './tokenizers.js'; |
| 10 | import { copyText } from './utils.js'; |
| 11 | |
| 12 | let PromptArrayItemForRawPromptDisplay; |
| 13 | let priorPromptArrayItemForRawPromptDisplay; |
| 14 | |
| 15 | const promptStorage = localforage.createInstance({ name: 'SillyTavern_Prompts' }); |
| 16 | export let itemizedPrompts = []; |
| 17 | |
| 18 | /** |
| 19 | * Gets the itemized prompts for a chat. |
| 20 | * @param {string} chatId Chat ID to load |
| 21 | */ |
| 22 | export async function loadItemizedPrompts(chatId) { |
| 23 | try { |
| 24 | if (!chatId) { |
| 25 | itemizedPrompts = []; |
| 26 | return; |
| 27 | } |
| 28 | |
| 29 | itemizedPrompts = await promptStorage.getItem(chatId); |
| 30 | |
| 31 | if (!itemizedPrompts) { |
| 32 | itemizedPrompts = []; |
| 33 | } |
| 34 | |
| 35 | await eventSource.emit(event_types.ITEMIZED_PROMPTS_LOADED, { chatId: chatId }); |
| 36 | } catch { |
| 37 | console.log('Error loading itemized prompts for chat', chatId); |
| 38 | itemizedPrompts = []; |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | /** |
| 43 | * Saves the itemized prompts for a chat. |
| 44 | * @param {string} chatId Chat ID to save itemized prompts for |
| 45 | */ |
| 46 | export async function saveItemizedPrompts(chatId) { |
| 47 | try { |
| 48 | if (!chatId) { |
| 49 | return; |
| 50 | } |
| 51 | |
| 52 | await promptStorage.setItem(chatId, itemizedPrompts); |
| 53 | await eventSource.emit(event_types.ITEMIZED_PROMPTS_SAVED, { chatId: chatId }); |
| 54 | } catch { |
| 55 | console.log('Error saving itemized prompts for chat', chatId); |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | /** |
| 60 | * Replaces the itemized prompt text for a message. |
| 61 | * @param {number} mesId Message ID to get itemized prompt for |
| 62 | * @param {string} promptText New raw prompt text |
| 63 | * @returns |
| 64 | */ |
| 65 | export async function replaceItemizedPromptText(mesId, promptText) { |
| 66 | if (!Array.isArray(itemizedPrompts)) { |
| 67 | itemizedPrompts = []; |
| 68 | } |
| 69 | |
| 70 | const itemizedPrompt = itemizedPrompts.find(x => x.mesId === mesId); |
| 71 | |
| 72 | if (!itemizedPrompt) { |
| 73 | return; |
| 74 | } |
| 75 | |
| 76 | itemizedPrompt.rawPrompt = promptText; |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * Deletes the itemized prompts for a chat. |
| 81 | * @param {string} chatId Chat ID to delete itemized prompts for |
| 82 | */ |
| 83 | export async function deleteItemizedPrompts(chatId) { |
| 84 | try { |
| 85 | if (!chatId) { |
| 86 | return; |
| 87 | } |
| 88 | |
| 89 | await promptStorage.removeItem(chatId); |
| 90 | await eventSource.emit(event_types.ITEMIZED_PROMPTS_DELETED, { chatId: chatId, all: false }); |
| 91 | } catch { |
| 92 | console.log('Error deleting itemized prompts for chat', chatId); |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | /** |
| 97 | * Empties the itemized prompts array and caches. |
| 98 | */ |
| 99 | export async function clearItemizedPrompts() { |
| 100 | try { |
| 101 | await promptStorage.clear(); |
| 102 | itemizedPrompts = []; |
| 103 | await eventSource.emit(event_types.ITEMIZED_PROMPTS_DELETED, { all: true }); |
| 104 | } catch { |
| 105 | console.log('Error clearing itemized prompts'); |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | export async function itemizedParams(itemizedPrompts, thisPromptSet, incomingMesId) { |
| 110 | const params = { |
| 111 | charDescriptionTokens: await getTokenCountAsync(itemizedPrompts[thisPromptSet].charDescription), |
| 112 | charPersonalityTokens: await getTokenCountAsync(itemizedPrompts[thisPromptSet].charPersonality), |
| 113 | scenarioTextTokens: await getTokenCountAsync(itemizedPrompts[thisPromptSet].scenarioText), |
| 114 | userPersonaStringTokens: await getTokenCountAsync(itemizedPrompts[thisPromptSet].userPersona), |
| 115 | worldInfoStringTokens: await getTokenCountAsync(itemizedPrompts[thisPromptSet].worldInfoString), |
| 116 | allAnchorsTokens: await getTokenCountAsync(itemizedPrompts[thisPromptSet].allAnchors), |
| 117 | summarizeStringTokens: await getTokenCountAsync(itemizedPrompts[thisPromptSet].summarizeString), |
| 118 | authorsNoteStringTokens: await getTokenCountAsync(itemizedPrompts[thisPromptSet].authorsNoteString), |
| 119 | smartContextStringTokens: await getTokenCountAsync(itemizedPrompts[thisPromptSet].smartContextString), |
| 120 | beforeScenarioAnchorTokens: await getTokenCountAsync(itemizedPrompts[thisPromptSet].beforeScenarioAnchor), |
| 121 | afterScenarioAnchorTokens: await getTokenCountAsync(itemizedPrompts[thisPromptSet].afterScenarioAnchor), |
| 122 | zeroDepthAnchorTokens: await getTokenCountAsync(itemizedPrompts[thisPromptSet].zeroDepthAnchor), // TODO: unused |
| 123 | thisPrompt_padding: itemizedPrompts[thisPromptSet].padding, |
| 124 | this_main_api: itemizedPrompts[thisPromptSet].main_api, |
| 125 | chatInjects: await getTokenCountAsync(itemizedPrompts[thisPromptSet].chatInjects), |
| 126 | chatVectorsStringTokens: await getTokenCountAsync(itemizedPrompts[thisPromptSet].chatVectorsString), |
| 127 | dataBankVectorsStringTokens: await getTokenCountAsync(itemizedPrompts[thisPromptSet].dataBankVectorsString), |
| 128 | modelUsed: chat[incomingMesId]?.extra?.model, |
| 129 | apiUsed: chat[incomingMesId]?.extra?.api, |
| 130 | presetName: itemizedPrompts[thisPromptSet].presetName || t`(Unknown)`, |
| 131 | messagesCount: String(itemizedPrompts[thisPromptSet].messagesCount ?? ''), |
| 132 | examplesCount: String(itemizedPrompts[thisPromptSet].examplesCount ?? ''), |
| 133 | }; |
| 134 | |
| 135 | const getFriendlyName = (value) => $(`#rm_api_block select option[value="${value}"]`).first().text() || value; |
| 136 | |
| 137 | if (params.apiUsed) { |
| 138 | params.apiUsed = getFriendlyName(params.apiUsed); |
| 139 | } |
| 140 | |
| 141 | if (params.this_main_api) { |
| 142 | params.mainApiFriendlyName = getFriendlyName(params.this_main_api); |
| 143 | } |
| 144 | |
| 145 | if (params.chatInjects) { |
| 146 | params.ActualChatHistoryTokens = params.ActualChatHistoryTokens - params.chatInjects; |
| 147 | } |
| 148 | |
| 149 | if (params.this_main_api == 'openai') { |
| 150 | //for OAI API |
| 151 | //console.log('-- Counting OAI Tokens'); |
| 152 | |
| 153 | //params.finalPromptTokens = itemizedPrompts[thisPromptSet].oaiTotalTokens; |
| 154 | params.oaiMainTokens = itemizedPrompts[thisPromptSet].oaiMainTokens; |
| 155 | params.oaiStartTokens = itemizedPrompts[thisPromptSet].oaiStartTokens; |
| 156 | params.ActualChatHistoryTokens = itemizedPrompts[thisPromptSet].oaiConversationTokens; |
| 157 | params.examplesStringTokens = itemizedPrompts[thisPromptSet].oaiExamplesTokens; |
| 158 | params.oaiPromptTokens = itemizedPrompts[thisPromptSet].oaiPromptTokens - (params.afterScenarioAnchorTokens + params.beforeScenarioAnchorTokens) + params.examplesStringTokens; |
| 159 | params.oaiBiasTokens = itemizedPrompts[thisPromptSet].oaiBiasTokens; |
| 160 | params.oaiJailbreakTokens = itemizedPrompts[thisPromptSet].oaiJailbreakTokens; |
| 161 | params.oaiNudgeTokens = itemizedPrompts[thisPromptSet].oaiNudgeTokens; |
| 162 | params.oaiImpersonateTokens = itemizedPrompts[thisPromptSet].oaiImpersonateTokens; |
| 163 | params.oaiNsfwTokens = itemizedPrompts[thisPromptSet].oaiNsfwTokens; |
| 164 | params.finalPromptTokens = |
| 165 | params.oaiStartTokens + |
| 166 | params.oaiPromptTokens + |
| 167 | params.oaiMainTokens + |
| 168 | params.oaiNsfwTokens + |
| 169 | params.oaiBiasTokens + |
| 170 | params.oaiImpersonateTokens + |
| 171 | params.oaiJailbreakTokens + |
| 172 | params.oaiNudgeTokens + |
| 173 | params.ActualChatHistoryTokens + |
| 174 | //charDescriptionTokens + |
| 175 | //charPersonalityTokens + |
| 176 | //allAnchorsTokens + |
| 177 | params.worldInfoStringTokens + |
| 178 | params.beforeScenarioAnchorTokens + |
| 179 | params.afterScenarioAnchorTokens; |
| 180 | // Max context size - max completion tokens |
| 181 | params.thisPrompt_max_context = (oai_settings.openai_max_context - oai_settings.openai_max_tokens); |
| 182 | |
| 183 | //console.log('-- applying % on OAI tokens'); |
| 184 | params.oaiStartTokensPercentage = ((params.oaiStartTokens / (params.finalPromptTokens)) * 100).toFixed(2); |
| 185 | params.storyStringTokensPercentage = (((params.afterScenarioAnchorTokens + params.beforeScenarioAnchorTokens + params.oaiPromptTokens) / (params.finalPromptTokens)) * 100).toFixed(2); |
| 186 | params.ActualChatHistoryTokensPercentage = ((params.ActualChatHistoryTokens / (params.finalPromptTokens)) * 100).toFixed(2); |
| 187 | params.promptBiasTokensPercentage = ((params.oaiBiasTokens / (params.finalPromptTokens)) * 100).toFixed(2); |
| 188 | params.worldInfoStringTokensPercentage = ((params.worldInfoStringTokens / (params.finalPromptTokens)) * 100).toFixed(2); |
| 189 | params.allAnchorsTokensPercentage = ((params.allAnchorsTokens / (params.finalPromptTokens)) * 100).toFixed(2); |
| 190 | params.selectedTokenizer = getFriendlyTokenizerName(params.this_main_api).tokenizerName; |
| 191 | params.oaiSystemTokens = params.oaiImpersonateTokens + params.oaiJailbreakTokens + params.oaiNudgeTokens + params.oaiStartTokens + params.oaiNsfwTokens + params.oaiMainTokens; |
| 192 | params.oaiSystemTokensPercentage = ((params.oaiSystemTokens / (params.finalPromptTokens)) * 100).toFixed(2); |
| 193 | } else { |
| 194 | //for non-OAI APIs |
| 195 | //console.log('-- Counting non-OAI Tokens'); |
| 196 | params.finalPromptTokens = await getTokenCountAsync(itemizedPrompts[thisPromptSet].finalPrompt); |
| 197 | params.storyStringTokens = await getTokenCountAsync(itemizedPrompts[thisPromptSet].storyString) - params.worldInfoStringTokens; |
| 198 | params.examplesStringTokens = await getTokenCountAsync(itemizedPrompts[thisPromptSet].examplesString); |
| 199 | params.mesSendStringTokens = await getTokenCountAsync(itemizedPrompts[thisPromptSet].mesSendString); |
| 200 | params.ActualChatHistoryTokens = params.mesSendStringTokens - (params.allAnchorsTokens - (params.beforeScenarioAnchorTokens + params.afterScenarioAnchorTokens)) + power_user.token_padding; |
| 201 | params.instructionTokens = await getTokenCountAsync(itemizedPrompts[thisPromptSet].instruction); |
| 202 | params.promptBiasTokens = await getTokenCountAsync(itemizedPrompts[thisPromptSet].promptBias); |
| 203 | |
| 204 | params.totalTokensInPrompt = |
| 205 | params.storyStringTokens + //chardefs total |
| 206 | params.worldInfoStringTokens + |
| 207 | params.examplesStringTokens + // example messages |
| 208 | params.ActualChatHistoryTokens + //chat history |
| 209 | params.allAnchorsTokens + // AN and/or legacy anchors |
| 210 | //afterScenarioAnchorTokens + //only counts if AN is set to 'after scenario' |
| 211 | //zeroDepthAnchorTokens + //same as above, even if AN not on 0 depth |
| 212 | params.promptBiasTokens; //{{}} |
| 213 | //- thisPrompt_padding; //not sure this way of calculating is correct, but the math results in same value as 'finalPrompt' |
| 214 | params.thisPrompt_max_context = itemizedPrompts[thisPromptSet].this_max_context; |
| 215 | params.thisPrompt_actual = params.thisPrompt_max_context - params.thisPrompt_padding; |
| 216 | |
| 217 | //console.log('-- applying % on non-OAI tokens'); |
| 218 | params.storyStringTokensPercentage = ((params.storyStringTokens / (params.totalTokensInPrompt)) * 100).toFixed(2); |
| 219 | params.ActualChatHistoryTokensPercentage = ((params.ActualChatHistoryTokens / (params.totalTokensInPrompt)) * 100).toFixed(2); |
| 220 | params.promptBiasTokensPercentage = ((params.promptBiasTokens / (params.totalTokensInPrompt)) * 100).toFixed(2); |
| 221 | params.worldInfoStringTokensPercentage = ((params.worldInfoStringTokens / (params.totalTokensInPrompt)) * 100).toFixed(2); |
| 222 | params.allAnchorsTokensPercentage = ((params.allAnchorsTokens / (params.totalTokensInPrompt)) * 100).toFixed(2); |
| 223 | params.selectedTokenizer = itemizedPrompts[thisPromptSet]?.tokenizer || getFriendlyTokenizerName(params.this_main_api).tokenizerName; |
| 224 | } |
| 225 | return params; |
| 226 | } |
| 227 | |
| 228 | export function findItemizedPromptSet(itemizedPrompts, incomingMesId) { |
| 229 | let thisPromptSet = undefined; |
| 230 | priorPromptArrayItemForRawPromptDisplay = -1; |
| 231 | |
| 232 | for (let i = 0; i < itemizedPrompts.length; i++) { |
| 233 | console.log(`looking for ${incomingMesId} vs ${itemizedPrompts[i].mesId}`); |
| 234 | if (itemizedPrompts[i].mesId === incomingMesId) { |
| 235 | console.log(`found matching mesID ${i}`); |
| 236 | thisPromptSet = i; |
| 237 | PromptArrayItemForRawPromptDisplay = i; |
| 238 | console.log(`wanting to raw display of ArrayItem: ${PromptArrayItemForRawPromptDisplay} which is mesID ${incomingMesId}`); |
| 239 | console.log(itemizedPrompts[thisPromptSet]); |
| 240 | break; |
| 241 | } else if (itemizedPrompts[i].rawPrompt) { |
| 242 | priorPromptArrayItemForRawPromptDisplay = i; |
| 243 | } |
| 244 | } |
| 245 | return thisPromptSet; |
| 246 | } |
| 247 | |
| 248 | export async function promptItemize(itemizedPrompts, requestedMesId) { |
| 249 | console.log('PROMPT ITEMIZE ENTERED'); |
| 250 | var incomingMesId = Number(requestedMesId); |
| 251 | console.debug(`looking for MesId ${incomingMesId}`); |
| 252 | var thisPromptSet = findItemizedPromptSet(itemizedPrompts, incomingMesId); |
| 253 | |
| 254 | if (thisPromptSet === undefined) { |
| 255 | console.log(`couldnt find the right mesId. looked for ${incomingMesId}`); |
| 256 | console.log(itemizedPrompts); |
| 257 | return null; |
| 258 | } |
| 259 | |
| 260 | const params = await itemizedParams(itemizedPrompts, thisPromptSet, incomingMesId); |
| 261 | const flatten = (rawPrompt) => Array.isArray(rawPrompt) ? rawPrompt.map(x => x.content).join('\n') : rawPrompt; |
| 262 | |
| 263 | const template = params.this_main_api == 'openai' |
| 264 | ? await renderTemplateAsync('itemizationChat', params) |
| 265 | : await renderTemplateAsync('itemizationText', params); |
| 266 | |
| 267 | const popup = new Popup(template, POPUP_TYPE.TEXT); |
| 268 | |
| 269 | /** @type {HTMLElement} */ |
| 270 | const diffPrevPrompt = popup.dlg.querySelector('#diffPrevPrompt'); |
| 271 | if (priorPromptArrayItemForRawPromptDisplay >= 0) { |
| 272 | diffPrevPrompt.style.display = ''; |
| 273 | diffPrevPrompt.addEventListener('click', function () { |
| 274 | const dmp = new DiffMatchPatch(); |
| 275 | const text1 = flatten(itemizedPrompts[priorPromptArrayItemForRawPromptDisplay].rawPrompt); |
| 276 | const text2 = flatten(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt); |
| 277 | |
| 278 | dmp.Diff_Timeout = 2.0; |
| 279 | |
| 280 | const d = dmp.diff_main(text1, text2); |
| 281 | let ds = dmp.diff_prettyHtml(d); |
| 282 | // make it readable |
| 283 | ds = ds.replaceAll('background:#e6ffe6;', 'background:#b9f3b9; color:black;'); |
| 284 | ds = ds.replaceAll('background:#ffe6e6;', 'background:#f5b4b4; color:black;'); |
| 285 | ds = ds.replaceAll('¶', ''); |
| 286 | const container = document.createElement('div'); |
| 287 | container.innerHTML = DOMPurify.sanitize(ds); |
| 288 | const rawPromptWrapper = document.getElementById('rawPromptWrapper'); |
| 289 | rawPromptWrapper.replaceChildren(container); |
| 290 | $('#rawPromptPopup').slideToggle(); |
| 291 | }); |
| 292 | } else { |
| 293 | diffPrevPrompt.style.display = 'none'; |
| 294 | } |
| 295 | popup.dlg.querySelector('#copyPromptToClipboard').addEventListener('pointerup', async function () { |
| 296 | let rawPrompt = itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt; |
| 297 | let rawPromptValues = rawPrompt; |
| 298 | |
| 299 | if (Array.isArray(rawPrompt)) { |
| 300 | rawPromptValues = rawPrompt.map(x => x.content).join('\n'); |
| 301 | } |
| 302 | |
| 303 | await copyText(rawPromptValues); |
| 304 | toastr.info(t`Copied!`); |
| 305 | }); |
| 306 | |
| 307 | popup.dlg.querySelector('#showRawPrompt').addEventListener('click', async function () { |
| 308 | //console.log(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt); |
| 309 | console.log(PromptArrayItemForRawPromptDisplay); |
| 310 | console.log(itemizedPrompts); |
| 311 | console.log(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt); |
| 312 | |
| 313 | const rawPrompt = flatten(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt); |
| 314 | |
| 315 | // Mobile needs special handholding. The side-view on the popup wouldn't work, |
| 316 | // so we just show an additional popup for this. |
| 317 | if (isMobile()) { |
| 318 | const content = document.createElement('div'); |
| 319 | content.classList.add('tokenItemizingMaintext'); |
| 320 | content.innerText = rawPrompt; |
| 321 | const popup = new Popup(content, POPUP_TYPE.TEXT, null, { allowVerticalScrolling: true, leftAlign: true }); |
| 322 | await popup.show(); |
| 323 | return; |
| 324 | } |
| 325 | |
| 326 | //let DisplayStringifiedPrompt = JSON.stringify(itemizedPrompts[PromptArrayItemForRawPromptDisplay].rawPrompt).replace(/\n+/g, '<br>'); |
| 327 | const rawPromptWrapper = document.getElementById('rawPromptWrapper'); |
| 328 | rawPromptWrapper.innerText = rawPrompt; |
| 329 | $('#rawPromptPopup').slideToggle(); |
| 330 | }); |
| 331 | |
| 332 | await popup.show(); |
| 333 | } |
| 334 | |
| 335 | export function initItemizedPrompts() { |
| 336 | registerDebugFunction('clearPrompts', 'Delete itemized prompts', 'Deletes all itemized prompts from the local storage.', async () => { |
| 337 | await clearItemizedPrompts(); |
| 338 | toastr.info('Itemized prompts deleted.'); |
| 339 | if (getCurrentChatId()) { |
| 340 | await reloadCurrentChat(); |
| 341 | } |
| 342 | }); |
| 343 | |
| 344 | $(document).on('pointerup', '.mes_prompt', async function () { |
| 345 | let mesIdForItemization = $(this).closest('.mes').attr('mesId'); |
| 346 | console.log(`looking for mesID: ${mesIdForItemization}`); |
| 347 | if (itemizedPrompts.length !== undefined && itemizedPrompts.length !== 0) { |
| 348 | await promptItemize(itemizedPrompts, mesIdForItemization); |
| 349 | } |
| 350 | }); |
| 351 | |
| 352 | eventSource.on(event_types.CHAT_DELETED, async (name) => { |
| 353 | await deleteItemizedPrompts(name); |
| 354 | }); |
| 355 | eventSource.on(event_types.GROUP_CHAT_DELETED, async (name) => { |
| 356 | await deleteItemizedPrompts(name); |
| 357 | }); |
| 358 | } |
| 359 | |
| 360 | /** |
| 361 | * Swaps the itemized prompts between two messages. Useful when moving messages around in the chat. |
| 362 | * @param {number} sourceMessageId Source message ID |
| 363 | * @param {number} targetMessageId Target message ID |
| 364 | */ |
| 365 | export function swapItemizedPrompts(sourceMessageId, targetMessageId) { |
| 366 | if (!Array.isArray(itemizedPrompts)) { |
| 367 | return; |
| 368 | } |
| 369 | |
| 370 | const sourcePrompts = itemizedPrompts.filter(x => x.mesId === sourceMessageId); |
| 371 | const targetPrompts = itemizedPrompts.filter(x => x.mesId === targetMessageId); |
| 372 | |
| 373 | sourcePrompts.forEach(prompt => { |
| 374 | prompt.mesId = targetMessageId; |
| 375 | }); |
| 376 | |
| 377 | targetPrompts.forEach(prompt => { |
| 378 | prompt.mesId = sourceMessageId; |
| 379 | }); |
| 380 | |
| 381 | itemizedPrompts.sort((a, b) => a.mesId - b.mesId); |
| 382 | } |
| 383 | |
| 384 | /** |
| 385 | * Deletes the itemized prompt for a specific message. |
| 386 | * Shifts down other itemized prompts as necessary. |
| 387 | * @param {number} messageId Message ID to delete itemized prompt for |
| 388 | */ |
| 389 | export function deleteItemizedPromptForMessage(messageId) { |
| 390 | if (!Array.isArray(itemizedPrompts)) { |
| 391 | return; |
| 392 | } |
| 393 | |
| 394 | itemizedPrompts = itemizedPrompts.filter(x => x.mesId !== messageId); |
| 395 | |
| 396 | for (const prompt of itemizedPrompts.filter(x => x.mesId > messageId)) { |
| 397 | prompt.mesId -= 1; |
| 398 | } |
| 399 | } |