@lvce-editor/chat-view 1.17.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1192,6 +1192,9 @@ const getComposerWidth = width => {
1192
1192
  const getMinComposerHeight = lineHeight => {
1193
1193
  return lineHeight + 8;
1194
1194
  };
1195
+ const getMaxComposerHeight = (lineHeight, maxComposerRows) => {
1196
+ return lineHeight * Math.max(1, maxComposerRows) + 8;
1197
+ };
1195
1198
  const estimateComposerHeight = (value, lineHeight) => {
1196
1199
  const lineCount = value.split('\n').length;
1197
1200
  return lineCount * lineHeight + 8;
@@ -1200,17 +1203,19 @@ const getComposerHeight = async (state, value, width = state.width) => {
1200
1203
  const {
1201
1204
  composerFontFamily,
1202
1205
  composerFontSize,
1203
- composerLineHeight
1206
+ composerLineHeight,
1207
+ maxComposerRows
1204
1208
  } = state;
1205
1209
  const minimumHeight = getMinComposerHeight(composerLineHeight);
1210
+ const maximumHeight = getMaxComposerHeight(composerLineHeight, maxComposerRows);
1206
1211
  const content = value || ' ';
1207
1212
  const composerWidth = getComposerWidth(width);
1208
1213
  try {
1209
1214
  const measuredHeight = await measureTextBlockHeight(content, composerFontFamily, composerFontSize, composerLineHeight, composerWidth);
1210
1215
  const height = Math.ceil(measuredHeight) + 8;
1211
- return Math.max(minimumHeight, height);
1216
+ return Math.max(minimumHeight, Math.min(maximumHeight, height));
1212
1217
  } catch {
1213
- return Math.max(minimumHeight, estimateComposerHeight(value, composerLineHeight));
1218
+ return Math.max(minimumHeight, Math.min(maximumHeight, estimateComposerHeight(value, composerLineHeight)));
1214
1219
  }
1215
1220
  };
1216
1221
  const getMinComposerHeightForState = state => {
@@ -1385,6 +1390,7 @@ const createDefaultState = () => {
1385
1390
  const composerLineHeight = 20;
1386
1391
  return {
1387
1392
  assetDir: '',
1393
+ chatListScrollTop: 0,
1388
1394
  composerFontFamily: 'system-ui',
1389
1395
  composerFontSize,
1390
1396
  composerHeight: composerLineHeight + 8,
@@ -1399,6 +1405,8 @@ const createDefaultState = () => {
1399
1405
  inputSource: 'script',
1400
1406
  lastSubmittedSessionId: '',
1401
1407
  listItemHeight: 40,
1408
+ maxComposerRows: 5,
1409
+ messagesScrollTop: 0,
1402
1410
  mockApiCommandId: '',
1403
1411
  models: getDefaultModels(),
1404
1412
  nextMessageId: 1,
@@ -1420,6 +1428,7 @@ const createDefaultState = () => {
1420
1428
  messages: [],
1421
1429
  title: defaultSessionTitle()
1422
1430
  }],
1431
+ streamingEnabled: false,
1423
1432
  tokensMax: 0,
1424
1433
  tokensUsed: 0,
1425
1434
  uid: 0,
@@ -1457,7 +1466,7 @@ const create = (uid, x, y, width, height, platform, assetDir) => {
1457
1466
  };
1458
1467
 
1459
1468
  const isEqual$1 = (oldState, newState) => {
1460
- return oldState.initial === newState.initial;
1469
+ return oldState.initial === newState.initial && oldState.composerHeight === newState.composerHeight && oldState.composerLineHeight === newState.composerLineHeight && oldState.composerFontFamily === newState.composerFontFamily && oldState.composerFontSize === newState.composerFontSize;
1461
1470
  };
1462
1471
 
1463
1472
  const diffFocus = (oldState, newState) => {
@@ -1471,12 +1480,17 @@ const isEqual = (oldState, newState) => {
1471
1480
  return oldState.composerValue === newState.composerValue && oldState.initial === newState.initial && oldState.renamingSessionId === newState.renamingSessionId && oldState.selectedModelId === newState.selectedModelId && oldState.selectedSessionId === newState.selectedSessionId && oldState.sessions === newState.sessions && oldState.tokensMax === newState.tokensMax && oldState.tokensUsed === newState.tokensUsed && oldState.usageOverviewEnabled === newState.usageOverviewEnabled && oldState.viewMode === newState.viewMode;
1472
1481
  };
1473
1482
 
1483
+ const diffScrollTop = (oldState, newState) => {
1484
+ return oldState.chatListScrollTop === newState.chatListScrollTop && oldState.messagesScrollTop === newState.messagesScrollTop;
1485
+ };
1486
+
1474
1487
  const RenderItems = 4;
1475
1488
  const RenderFocus = 6;
1476
1489
  const RenderFocusContext = 7;
1477
1490
  const RenderValue = 8;
1478
1491
  const RenderCss = 10;
1479
1492
  const RenderIncremental = 11;
1493
+ const RenderScrollTop = 12;
1480
1494
 
1481
1495
  const diffValue = (oldState, newState) => {
1482
1496
  if (oldState.composerValue === newState.composerValue) {
@@ -1485,8 +1499,8 @@ const diffValue = (oldState, newState) => {
1485
1499
  return newState.inputSource !== 'script';
1486
1500
  };
1487
1501
 
1488
- const modules = [isEqual, diffValue, diffFocus, isEqual$1, diffFocus];
1489
- const numbers = [RenderIncremental, RenderValue, RenderFocus, RenderCss, RenderFocusContext];
1502
+ const modules = [isEqual, diffValue, diffFocus, isEqual$1, diffFocus, diffScrollTop];
1503
+ const numbers = [RenderIncremental, RenderValue, RenderFocus, RenderCss, RenderFocusContext, RenderScrollTop];
1490
1504
 
1491
1505
  const diff = (oldState, newState) => {
1492
1506
  const diffResult = [];
@@ -1519,6 +1533,7 @@ const FocusSelector = 'Viewlet.focusSelector';
1519
1533
  const SetCss = 'Viewlet.setCss';
1520
1534
  const SetDom2 = 'Viewlet.setDom2';
1521
1535
  const SetFocusContext = 'Viewlet.setFocusContext';
1536
+ const SetProperty = 'Viewlet.setProperty';
1522
1537
  const SetValueByName = 'Viewlet.setValueByName';
1523
1538
  const SetPatches = 'Viewlet.setPatches';
1524
1539
 
@@ -2495,7 +2510,16 @@ const executeChatTool = async (name, rawArguments, options) => {
2495
2510
  });
2496
2511
  };
2497
2512
 
2498
- const getOpenApiApiEndpoint = openApiApiBaseUrl => {
2513
+ const getClientRequestIdHeader = () => {
2514
+ return {
2515
+ 'x-client-request-id': crypto.randomUUID()
2516
+ };
2517
+ };
2518
+
2519
+ const getOpenApiApiEndpoint = (openApiApiBaseUrl, stream) => {
2520
+ if (stream) {
2521
+ return `${openApiApiBaseUrl}/chat/completions?stream=true`;
2522
+ }
2499
2523
  return `${openApiApiBaseUrl}/chat/completions`;
2500
2524
  };
2501
2525
 
@@ -2520,6 +2544,148 @@ const getTextContent = content => {
2520
2544
  return textParts.join('\n');
2521
2545
  };
2522
2546
 
2547
+ const getStreamChunkText = content => {
2548
+ if (typeof content === 'string') {
2549
+ return content;
2550
+ }
2551
+ if (!Array.isArray(content)) {
2552
+ return '';
2553
+ }
2554
+ return content.map(part => {
2555
+ if (!part || typeof part !== 'object') {
2556
+ return '';
2557
+ }
2558
+ const text = Reflect.get(part, 'text');
2559
+ return typeof text === 'string' ? text : '';
2560
+ }).join('');
2561
+ };
2562
+ const parseSseEvent = eventChunk => {
2563
+ const lines = eventChunk.split('\n');
2564
+ const dataLines = [];
2565
+ for (const line of lines) {
2566
+ if (!line.startsWith('data:')) {
2567
+ continue;
2568
+ }
2569
+ dataLines.push(line.slice(5).trimStart());
2570
+ }
2571
+ return dataLines;
2572
+ };
2573
+ const parseOpenApiStream = async (response, onTextChunk) => {
2574
+ if (!response.body) {
2575
+ return {
2576
+ details: 'request-failed',
2577
+ type: 'error'
2578
+ };
2579
+ }
2580
+ const reader = response.body.getReader();
2581
+ const decoder = new TextDecoder();
2582
+ let remainder = '';
2583
+ let text = '';
2584
+ let done = false;
2585
+ while (!done) {
2586
+ const {
2587
+ done: streamDone,
2588
+ value
2589
+ } = await reader.read();
2590
+ if (streamDone) {
2591
+ done = true;
2592
+ } else if (value) {
2593
+ remainder += decoder.decode(value, {
2594
+ stream: true
2595
+ });
2596
+ }
2597
+ while (true) {
2598
+ const separatorIndex = remainder.indexOf('\n\n');
2599
+ if (separatorIndex === -1) {
2600
+ break;
2601
+ }
2602
+ const rawEvent = remainder.slice(0, separatorIndex);
2603
+ remainder = remainder.slice(separatorIndex + 2);
2604
+ const dataLines = parseSseEvent(rawEvent);
2605
+ if (dataLines.length === 0) {
2606
+ continue;
2607
+ }
2608
+ for (const line of dataLines) {
2609
+ if (line === '[DONE]') {
2610
+ done = true;
2611
+ break;
2612
+ }
2613
+ let parsed;
2614
+ try {
2615
+ parsed = JSON.parse(line);
2616
+ } catch {
2617
+ continue;
2618
+ }
2619
+ if (!parsed || typeof parsed !== 'object') {
2620
+ continue;
2621
+ }
2622
+ const choices = Reflect.get(parsed, 'choices');
2623
+ if (!Array.isArray(choices)) {
2624
+ continue;
2625
+ }
2626
+ const firstChoice = choices[0];
2627
+ if (!firstChoice || typeof firstChoice !== 'object') {
2628
+ continue;
2629
+ }
2630
+ const delta = Reflect.get(firstChoice, 'delta');
2631
+ if (!delta || typeof delta !== 'object') {
2632
+ continue;
2633
+ }
2634
+ const content = Reflect.get(delta, 'content');
2635
+ const chunkText = getStreamChunkText(content);
2636
+ if (!chunkText) {
2637
+ continue;
2638
+ }
2639
+ text += chunkText;
2640
+ if (onTextChunk) {
2641
+ await onTextChunk(chunkText);
2642
+ }
2643
+ }
2644
+ }
2645
+ }
2646
+ if (remainder) {
2647
+ const dataLines = parseSseEvent(remainder);
2648
+ for (const line of dataLines) {
2649
+ if (line === '[DONE]') {
2650
+ continue;
2651
+ }
2652
+ let parsed;
2653
+ try {
2654
+ parsed = JSON.parse(line);
2655
+ } catch {
2656
+ continue;
2657
+ }
2658
+ if (!parsed || typeof parsed !== 'object') {
2659
+ continue;
2660
+ }
2661
+ const choices = Reflect.get(parsed, 'choices');
2662
+ if (!Array.isArray(choices)) {
2663
+ continue;
2664
+ }
2665
+ const firstChoice = choices[0];
2666
+ if (!firstChoice || typeof firstChoice !== 'object') {
2667
+ continue;
2668
+ }
2669
+ const delta = Reflect.get(firstChoice, 'delta');
2670
+ if (!delta || typeof delta !== 'object') {
2671
+ continue;
2672
+ }
2673
+ const content = Reflect.get(delta, 'content');
2674
+ const chunkText = getStreamChunkText(content);
2675
+ if (!chunkText) {
2676
+ continue;
2677
+ }
2678
+ text += chunkText;
2679
+ if (onTextChunk) {
2680
+ await onTextChunk(chunkText);
2681
+ }
2682
+ }
2683
+ }
2684
+ return {
2685
+ text,
2686
+ type: 'success'
2687
+ };
2688
+ };
2523
2689
  const getOpenApiErrorDetails = async response => {
2524
2690
  let parsed;
2525
2691
  try {
@@ -2543,7 +2709,13 @@ const getOpenApiErrorDetails = async response => {
2543
2709
  errorType: typeof errorType === 'string' ? errorType : undefined
2544
2710
  };
2545
2711
  };
2546
- const getOpenApiAssistantText = async (messages, modelId, openApiApiKey, openApiApiBaseUrl, assetDir, platform) => {
2712
+ const getOpenApiAssistantText = async (messages, modelId, openApiApiKey, openApiApiBaseUrl, assetDir, platform, options) => {
2713
+ const {
2714
+ onTextChunk,
2715
+ stream
2716
+ } = options ?? {
2717
+ stream: false
2718
+ };
2547
2719
  const completionMessages = messages.map(message => ({
2548
2720
  content: message.text,
2549
2721
  role: message.role
@@ -2553,16 +2725,20 @@ const getOpenApiAssistantText = async (messages, modelId, openApiApiKey, openApi
2553
2725
  for (let i = 0; i <= maxToolIterations; i++) {
2554
2726
  let response;
2555
2727
  try {
2556
- response = await fetch(getOpenApiApiEndpoint(openApiApiBaseUrl), {
2728
+ response = await fetch(getOpenApiApiEndpoint(openApiApiBaseUrl, stream), {
2557
2729
  body: JSON.stringify({
2558
2730
  messages: completionMessages,
2559
2731
  model: modelId,
2732
+ ...(stream ? {
2733
+ stream: true
2734
+ } : {}),
2560
2735
  tool_choice: 'auto',
2561
2736
  tools
2562
2737
  }),
2563
2738
  headers: {
2564
2739
  Authorization: `Bearer ${openApiApiKey}`,
2565
- 'Content-Type': 'application/json'
2740
+ 'Content-Type': 'application/json',
2741
+ ...getClientRequestIdHeader()
2566
2742
  },
2567
2743
  method: 'POST'
2568
2744
  });
@@ -2587,6 +2763,9 @@ const getOpenApiAssistantText = async (messages, modelId, openApiApiKey, openApi
2587
2763
  type: 'error'
2588
2764
  };
2589
2765
  }
2766
+ if (stream) {
2767
+ return parseOpenApiStream(response, onTextChunk);
2768
+ }
2590
2769
  let parsed;
2591
2770
  try {
2592
2771
  parsed = await response.json();
@@ -2758,7 +2937,8 @@ const getOpenRouterLimitInfo = async (openRouterApiKey, openRouterApiBaseUrl) =>
2758
2937
  try {
2759
2938
  response = await fetch(getOpenRouterKeyEndpoint(openRouterApiBaseUrl), {
2760
2939
  headers: {
2761
- Authorization: `Bearer ${openRouterApiKey}`
2940
+ Authorization: `Bearer ${openRouterApiKey}`,
2941
+ ...getClientRequestIdHeader()
2762
2942
  },
2763
2943
  method: 'GET'
2764
2944
  });
@@ -2816,7 +2996,8 @@ const getOpenRouterAssistantText = async (messages, modelId, openRouterApiKey, o
2816
2996
  }),
2817
2997
  headers: {
2818
2998
  Authorization: `Bearer ${openRouterApiKey}`,
2819
- 'Content-Type': 'application/json'
2999
+ 'Content-Type': 'application/json',
3000
+ ...getClientRequestIdHeader()
2820
3001
  },
2821
3002
  method: 'POST'
2822
3003
  });
@@ -2965,7 +3146,7 @@ const getOpenRouterErrorMessage = errorResult => {
2965
3146
  }
2966
3147
  };
2967
3148
 
2968
- /* eslint-disable @cspell/spellchecker */
3149
+ // cspell:ignore openrouter
2969
3150
  const getOpenRouterModelId = selectedModelId => {
2970
3151
  const openRouterPrefix = 'openrouter/';
2971
3152
  if (selectedModelId.toLowerCase().startsWith(openRouterPrefix)) {
@@ -2984,7 +3165,7 @@ const isOpenApiModel = (selectedModelId, models) => {
2984
3165
  return normalizedModelId.startsWith('openapi/') || normalizedModelId.startsWith('openai/');
2985
3166
  };
2986
3167
 
2987
- /* eslint-disable @cspell/spellchecker */
3168
+ // cspell:ignore openrouter
2988
3169
 
2989
3170
  const isOpenRouterModel = (selectedModelId, models) => {
2990
3171
  const selectedModel = models.find(model => model.id === selectedModelId);
@@ -3001,12 +3182,14 @@ const getAiResponse = async ({
3001
3182
  mockApiCommandId,
3002
3183
  models,
3003
3184
  nextMessageId,
3185
+ onTextChunk,
3004
3186
  openApiApiBaseUrl,
3005
3187
  openApiApiKey,
3006
3188
  openRouterApiBaseUrl,
3007
3189
  openRouterApiKey,
3008
3190
  platform,
3009
3191
  selectedModelId,
3192
+ streamingEnabled = false,
3010
3193
  useMockApi,
3011
3194
  userText
3012
3195
  }) => {
@@ -3015,7 +3198,10 @@ const getAiResponse = async ({
3015
3198
  const usesOpenRouterModel = isOpenRouterModel(selectedModelId, models);
3016
3199
  if (usesOpenApiModel) {
3017
3200
  if (openApiApiKey) {
3018
- const result = await getOpenApiAssistantText(messages, getOpenApiModelId(selectedModelId), openApiApiKey, openApiApiBaseUrl, assetDir, platform);
3201
+ const result = await getOpenApiAssistantText(messages, getOpenApiModelId(selectedModelId), openApiApiKey, openApiApiBaseUrl, assetDir, platform, {
3202
+ onTextChunk,
3203
+ stream: streamingEnabled
3204
+ });
3019
3205
  if (result.type === 'success') {
3020
3206
  const {
3021
3207
  text: assistantText
@@ -3233,6 +3419,37 @@ const focusInput = state => {
3233
3419
  };
3234
3420
  };
3235
3421
 
3422
+ const appendMessageToSelectedSession = (sessions, selectedSessionId, message) => {
3423
+ return sessions.map(session => {
3424
+ if (session.id !== selectedSessionId) {
3425
+ return session;
3426
+ }
3427
+ return {
3428
+ ...session,
3429
+ messages: [...session.messages, message]
3430
+ };
3431
+ });
3432
+ };
3433
+ const updateMessageTextInSelectedSession = (sessions, selectedSessionId, messageId, text, inProgress) => {
3434
+ return sessions.map(session => {
3435
+ if (session.id !== selectedSessionId) {
3436
+ return session;
3437
+ }
3438
+ return {
3439
+ ...session,
3440
+ messages: session.messages.map(message => {
3441
+ if (message.id !== messageId) {
3442
+ return message;
3443
+ }
3444
+ return {
3445
+ ...message,
3446
+ inProgress,
3447
+ text
3448
+ };
3449
+ })
3450
+ };
3451
+ });
3452
+ };
3236
3453
  const handleSubmit = async state => {
3237
3454
  const {
3238
3455
  assetDir,
@@ -3248,6 +3465,7 @@ const handleSubmit = async state => {
3248
3465
  selectedModelId,
3249
3466
  selectedSessionId,
3250
3467
  sessions,
3468
+ streamingEnabled,
3251
3469
  useMockApi,
3252
3470
  viewMode
3253
3471
  } = state;
@@ -3265,6 +3483,18 @@ const handleSubmit = async state => {
3265
3483
  text: userText,
3266
3484
  time: userTime
3267
3485
  };
3486
+ const assistantMessageId = `message-${nextMessageId + 1}`;
3487
+ const assistantTime = new Date().toLocaleTimeString([], {
3488
+ hour: '2-digit',
3489
+ minute: '2-digit'
3490
+ });
3491
+ const inProgressAssistantMessage = {
3492
+ id: assistantMessageId,
3493
+ inProgress: true,
3494
+ role: 'assistant',
3495
+ text: '',
3496
+ time: assistantTime
3497
+ };
3268
3498
  let workingSessions = sessions;
3269
3499
  if (viewMode === 'detail') {
3270
3500
  const loadedSession = await getChatSession(selectedSessionId);
@@ -3282,7 +3512,7 @@ const handleSubmit = async state => {
3282
3512
  const newSessionId = generateSessionId();
3283
3513
  const newSession = {
3284
3514
  id: newSessionId,
3285
- messages: [userMessage],
3515
+ messages: streamingEnabled ? [userMessage, inProgressAssistantMessage] : [userMessage],
3286
3516
  title: `Chat ${workingSessions.length + 1}`
3287
3517
  };
3288
3518
  await saveChatSession(newSession);
@@ -3298,15 +3528,8 @@ const handleSubmit = async state => {
3298
3528
  viewMode: 'detail'
3299
3529
  });
3300
3530
  } else {
3301
- const updatedSessions = workingSessions.map(session => {
3302
- if (session.id !== selectedSessionId) {
3303
- return session;
3304
- }
3305
- return {
3306
- ...session,
3307
- messages: [...session.messages, userMessage]
3308
- };
3309
- });
3531
+ const updatedWithUser = appendMessageToSelectedSession(workingSessions, selectedSessionId, userMessage);
3532
+ const updatedSessions = streamingEnabled ? appendMessageToSelectedSession(updatedWithUser, selectedSessionId, inProgressAssistantMessage) : updatedWithUser;
3310
3533
  const selectedSession = updatedSessions.find(session => session.id === selectedSessionId);
3311
3534
  if (selectedSession) {
3312
3535
  await saveChatSession(selectedSession);
@@ -3324,39 +3547,56 @@ const handleSubmit = async state => {
3324
3547
  set(state.uid, state, optimisticState);
3325
3548
  // @ts-ignore
3326
3549
  await invoke('Chat.rerender');
3327
- const selectedOptimisticSession = optimisticState.sessions.find(session => session.id === optimisticState.selectedSessionId);
3328
- const messages = selectedOptimisticSession?.messages ?? [];
3550
+ let latestState = optimisticState;
3551
+ let previousState = optimisticState;
3552
+ const selectedOptimisticSession = latestState.sessions.find(session => session.id === latestState.selectedSessionId);
3553
+ const messages = (selectedOptimisticSession?.messages ?? []).filter(message => !message.inProgress);
3554
+ const onTextChunk = streamingEnabled ? async chunk => {
3555
+ const selectedSession = latestState.sessions.find(session => session.id === latestState.selectedSessionId);
3556
+ if (!selectedSession) {
3557
+ return;
3558
+ }
3559
+ const assistantMessage = selectedSession.messages.find(message => message.id === assistantMessageId);
3560
+ if (!assistantMessage) {
3561
+ return;
3562
+ }
3563
+ const updatedText = assistantMessage.text + chunk;
3564
+ const updatedSessions = updateMessageTextInSelectedSession(latestState.sessions, latestState.selectedSessionId, assistantMessageId, updatedText, true);
3565
+ const nextState = {
3566
+ ...latestState,
3567
+ sessions: updatedSessions
3568
+ };
3569
+ set(state.uid, previousState, nextState);
3570
+ previousState = nextState;
3571
+ latestState = nextState;
3572
+ // @ts-ignore
3573
+ await invoke('Chat.rerender');
3574
+ } : undefined;
3329
3575
  const assistantMessage = await getAiResponse({
3330
3576
  assetDir,
3331
3577
  messages,
3332
3578
  mockApiCommandId,
3333
3579
  models,
3334
3580
  nextMessageId: optimisticState.nextMessageId,
3581
+ onTextChunk,
3335
3582
  openApiApiBaseUrl,
3336
3583
  openApiApiKey,
3337
3584
  openRouterApiBaseUrl,
3338
3585
  openRouterApiKey,
3339
3586
  platform,
3340
3587
  selectedModelId,
3588
+ streamingEnabled,
3341
3589
  useMockApi,
3342
3590
  userText
3343
3591
  });
3344
- const updatedSessions = optimisticState.sessions.map(session => {
3345
- if (session.id !== optimisticState.selectedSessionId) {
3346
- return session;
3347
- }
3348
- return {
3349
- ...session,
3350
- messages: [...session.messages, assistantMessage]
3351
- };
3352
- });
3353
- const selectedSession = updatedSessions.find(session => session.id === optimisticState.selectedSessionId);
3592
+ const updatedSessions = streamingEnabled ? updateMessageTextInSelectedSession(latestState.sessions, latestState.selectedSessionId, assistantMessageId, assistantMessage.text, false) : appendMessageToSelectedSession(latestState.sessions, latestState.selectedSessionId, assistantMessage);
3593
+ const selectedSession = updatedSessions.find(session => session.id === latestState.selectedSessionId);
3354
3594
  if (selectedSession) {
3355
3595
  await saveChatSession(selectedSession);
3356
3596
  }
3357
3597
  return focusInput({
3358
- ...optimisticState,
3359
- nextMessageId: optimisticState.nextMessageId + 1,
3598
+ ...latestState,
3599
+ nextMessageId: latestState.nextMessageId + 1,
3360
3600
  sessions: updatedSessions
3361
3601
  });
3362
3602
  };
@@ -3405,7 +3645,7 @@ const OpenApiApiKeyInput = 'open-api-api-key';
3405
3645
  const SaveOpenApiApiKey = 'save-openapi-api-key';
3406
3646
  const OpenOpenApiApiKeySettings = 'open-openapi-api-key-settings';
3407
3647
 
3408
- /* eslint-disable @cspell/spellchecker */
3648
+ // cspell:ignore openrouter
3409
3649
  const OpenRouterApiKeyInput = 'open-router-api-key';
3410
3650
  const SaveOpenRouterApiKey = 'save-openrouter-api-key';
3411
3651
  const OpenOpenRouterApiKeySettings = 'open-openrouter-api-key-settings';
@@ -3652,7 +3892,29 @@ const handleModelChange = async (state, value) => {
3652
3892
  };
3653
3893
 
3654
3894
  const handleNewline = async state => {
3655
- return handleInput(state, Composer, `${state.composerValue}\n`);
3895
+ const {
3896
+ composerValue
3897
+ } = state;
3898
+ return handleInput(state, Composer, `${composerValue}\n`);
3899
+ };
3900
+
3901
+ const handleChatListScroll = async (state, chatListScrollTop) => {
3902
+ if (state.chatListScrollTop === chatListScrollTop) {
3903
+ return state;
3904
+ }
3905
+ return {
3906
+ ...state,
3907
+ chatListScrollTop
3908
+ };
3909
+ };
3910
+ const handleMessagesScroll = async (state, messagesScrollTop) => {
3911
+ if (state.messagesScrollTop === messagesScrollTop) {
3912
+ return state;
3913
+ }
3914
+ return {
3915
+ ...state,
3916
+ messagesScrollTop
3917
+ };
3656
3918
  };
3657
3919
 
3658
3920
  const id = 7201;
@@ -3681,6 +3943,32 @@ const isObject = value => {
3681
3943
  return typeof value === 'object' && value !== null;
3682
3944
  };
3683
3945
 
3946
+ const getSavedChatListScrollTop = savedState => {
3947
+ if (!isObject(savedState)) {
3948
+ return undefined;
3949
+ }
3950
+ const {
3951
+ chatListScrollTop
3952
+ } = savedState;
3953
+ if (typeof chatListScrollTop !== 'number') {
3954
+ return undefined;
3955
+ }
3956
+ return chatListScrollTop;
3957
+ };
3958
+
3959
+ const getSavedMessagesScrollTop = savedState => {
3960
+ if (!isObject(savedState)) {
3961
+ return undefined;
3962
+ }
3963
+ const {
3964
+ messagesScrollTop
3965
+ } = savedState;
3966
+ if (typeof messagesScrollTop !== 'number') {
3967
+ return undefined;
3968
+ }
3969
+ return messagesScrollTop;
3970
+ };
3971
+
3684
3972
  const getSavedSelectedModelId = savedState => {
3685
3973
  if (!isObject(savedState)) {
3686
3974
  return undefined;
@@ -3733,6 +4021,39 @@ const getSavedViewMode = savedState => {
3733
4021
  return viewMode;
3734
4022
  };
3735
4023
 
4024
+ const loadOpenApiApiKey = async () => {
4025
+ try {
4026
+ const savedOpenApiKey = await get('secrets.openApiKey');
4027
+ if (typeof savedOpenApiKey === 'string' && savedOpenApiKey) {
4028
+ return savedOpenApiKey;
4029
+ }
4030
+ const legacySavedOpenApiApiKey = await get('secrets.openApiApiKey');
4031
+ if (typeof legacySavedOpenApiApiKey === 'string' && legacySavedOpenApiApiKey) {
4032
+ return legacySavedOpenApiApiKey;
4033
+ }
4034
+ const legacySavedOpenAiApiKey = await get('secrets.openAiApiKey');
4035
+ return typeof legacySavedOpenAiApiKey === 'string' ? legacySavedOpenAiApiKey : '';
4036
+ } catch {
4037
+ return '';
4038
+ }
4039
+ };
4040
+ const loadOpenRouterApiKey = async () => {
4041
+ try {
4042
+ const savedOpenRouterApiKey = await get('secrets.openRouterApiKey');
4043
+ return typeof savedOpenRouterApiKey === 'string' ? savedOpenRouterApiKey : '';
4044
+ } catch {
4045
+ return '';
4046
+ }
4047
+ };
4048
+ const loadPreferences = async () => {
4049
+ const openApiApiKey = await loadOpenApiApiKey();
4050
+ const openRouterApiKey = await loadOpenRouterApiKey();
4051
+ return {
4052
+ openApiApiKey,
4053
+ openRouterApiKey
4054
+ };
4055
+ };
4056
+
3736
4057
  const toSummarySession = session => {
3737
4058
  return {
3738
4059
  id: session.id,
@@ -3758,30 +4079,10 @@ const loadSelectedSessionMessages = async (sessions, selectedSessionId) => {
3758
4079
  const loadContent = async (state, savedState) => {
3759
4080
  const savedSelectedModelId = getSavedSelectedModelId(savedState);
3760
4081
  const savedViewMode = getSavedViewMode(savedState);
3761
- let openApiApiKey = '';
3762
- try {
3763
- const savedOpenApiKey = await get('secrets.openApiKey');
3764
- if (typeof savedOpenApiKey === 'string' && savedOpenApiKey) {
3765
- openApiApiKey = savedOpenApiKey;
3766
- } else {
3767
- const legacySavedOpenApiApiKey = await get('secrets.openApiApiKey');
3768
- if (typeof legacySavedOpenApiApiKey === 'string' && legacySavedOpenApiApiKey) {
3769
- openApiApiKey = legacySavedOpenApiApiKey;
3770
- } else {
3771
- const legacySavedOpenAiApiKey = await get('secrets.openAiApiKey');
3772
- openApiApiKey = typeof legacySavedOpenAiApiKey === 'string' ? legacySavedOpenAiApiKey : '';
3773
- }
3774
- }
3775
- } catch {
3776
- openApiApiKey = '';
3777
- }
3778
- let openRouterApiKey = '';
3779
- try {
3780
- const savedOpenRouterApiKey = await get('secrets.openRouterApiKey');
3781
- openRouterApiKey = typeof savedOpenRouterApiKey === 'string' ? savedOpenRouterApiKey : '';
3782
- } catch {
3783
- openRouterApiKey = '';
3784
- }
4082
+ const {
4083
+ openApiApiKey,
4084
+ openRouterApiKey
4085
+ } = await loadPreferences();
3785
4086
  const legacySavedSessions = getSavedSessions(savedState);
3786
4087
  const storedSessions = await listChatSessions();
3787
4088
  let sessions = storedSessions;
@@ -3799,6 +4100,8 @@ const loadContent = async (state, savedState) => {
3799
4100
  }
3800
4101
  const preferredSessionId = getSavedSelectedSessionId(savedState) || state.selectedSessionId;
3801
4102
  const preferredModelId = savedSelectedModelId || state.selectedModelId;
4103
+ const chatListScrollTop = getSavedChatListScrollTop(savedState) ?? state.chatListScrollTop;
4104
+ const messagesScrollTop = getSavedMessagesScrollTop(savedState) ?? state.messagesScrollTop;
3802
4105
  const selectedModelId = state.models.some(model => model.id === preferredModelId) ? preferredModelId : state.models[0]?.id || '';
3803
4106
  const selectedSessionId = sessions.some(session => session.id === preferredSessionId) ? preferredSessionId : sessions[0]?.id || '';
3804
4107
  sessions = await loadSelectedSessionMessages(sessions, selectedSessionId);
@@ -3806,7 +4109,9 @@ const loadContent = async (state, savedState) => {
3806
4109
  const viewMode = sessions.length === 0 || !selectedSessionId ? 'list' : preferredViewMode === 'detail' ? 'detail' : 'list';
3807
4110
  return {
3808
4111
  ...state,
4112
+ chatListScrollTop,
3809
4113
  initial: false,
4114
+ messagesScrollTop,
3810
4115
  openApiApiKey,
3811
4116
  openApiApiKeyInput: openApiApiKey,
3812
4117
  openRouterApiKey,
@@ -3852,12 +4157,22 @@ const openMockSession = async (state, mockSessionId, mockChatMessages) => {
3852
4157
  };
3853
4158
  };
3854
4159
 
4160
+ const getCss = composerHeight => {
4161
+ return `:root {
4162
+ --ChatInputBoxHeight: ${composerHeight}px;
4163
+ }`;
4164
+ };
4165
+
3855
4166
  // TODO render things like scrollbar height,scrollbar offset, textarea height,
3856
4167
  // list height
3857
- const css = `
3858
- `;
4168
+
3859
4169
  const renderCss = (oldState, newState) => {
3860
- return [SetCss, newState.uid, css];
4170
+ const {
4171
+ composerHeight,
4172
+ uid
4173
+ } = newState;
4174
+ const css = getCss(composerHeight);
4175
+ return [SetCss, uid, css];
3861
4176
  };
3862
4177
 
3863
4178
  const getFocusSelector = focus => {
@@ -3933,6 +4248,8 @@ const HandleClickList = 17;
3933
4248
  const HandleClickDelete = 18;
3934
4249
  const HandleSubmit = 19;
3935
4250
  const HandleModelChange = 20;
4251
+ const HandleChatListScroll = 21;
4252
+ const HandleMessagesScroll = 22;
3936
4253
 
3937
4254
  const getModelLabel = model => {
3938
4255
  if (model.provider === 'openRouter') {
@@ -4029,7 +4346,7 @@ const getChatSendAreaDom = (composerValue, models, selectedModelId, usageOvervie
4029
4346
  onFocus: HandleFocus,
4030
4347
  onInput: HandleInput,
4031
4348
  placeholder: composePlaceholder(),
4032
- style: `height:${composerHeight}px;font-size:${composerFontSize}px;font-family:${composerFontFamily};line-height:${composerLineHeight}px;`,
4349
+ // style: `height:${composerHeight}px;font-size:${composerFontSize}px;font-family:${composerFontFamily};line-height:${composerLineHeight}px;`,
4033
4350
  type: TextArea,
4034
4351
  value: composerValue
4035
4352
  }, {
@@ -4240,18 +4557,20 @@ const getEmptyMessagesDom = () => {
4240
4557
  }, text(startConversation())];
4241
4558
  };
4242
4559
 
4243
- const getMessagesDom = (messages, openRouterApiKeyInput, openApiApiKeyInput = '', openRouterApiKeyState = 'idle') => {
4560
+ const getMessagesDom = (messages, openRouterApiKeyInput, openApiApiKeyInput = '', openRouterApiKeyState = 'idle', messagesScrollTop = 0) => {
4244
4561
  if (messages.length === 0) {
4245
4562
  return getEmptyMessagesDom();
4246
4563
  }
4247
4564
  return [{
4248
4565
  childCount: messages.length,
4249
4566
  className: 'ChatMessages',
4567
+ onScroll: HandleMessagesScroll,
4568
+ scrollTop: messagesScrollTop,
4250
4569
  type: Div
4251
4570
  }, ...messages.flatMap(message => getChatMessageDom(message, openRouterApiKeyInput, openApiApiKeyInput, openRouterApiKeyState))];
4252
4571
  };
4253
4572
 
4254
- const getChatModeDetailVirtualDom = (sessions, selectedSessionId, composerValue, openRouterApiKeyInput, openApiApiKeyInput, models, selectedModelId, usageOverviewEnabled, tokensUsed, tokensMax, openRouterApiKeyState = 'idle', composerHeight = 28, composerFontSize = 13, composerFontFamily = 'system-ui', composerLineHeight = 20) => {
4573
+ const getChatModeDetailVirtualDom = (sessions, selectedSessionId, composerValue, openRouterApiKeyInput, openApiApiKeyInput, models, selectedModelId, usageOverviewEnabled, tokensUsed, tokensMax, openRouterApiKeyState = 'idle', composerHeight = 28, composerFontSize = 13, composerFontFamily = 'system-ui', composerLineHeight = 20, messagesScrollTop = 0) => {
4255
4574
  const selectedSession = sessions.find(session => session.id === selectedSessionId);
4256
4575
  const selectedSessionTitle = selectedSession?.title || chatTitle();
4257
4576
  const messages = selectedSession ? selectedSession.messages : [];
@@ -4259,7 +4578,7 @@ const getChatModeDetailVirtualDom = (sessions, selectedSessionId, composerValue,
4259
4578
  childCount: 3,
4260
4579
  className: mergeClassNames(Viewlet, Chat),
4261
4580
  type: Div
4262
- }, ...getChatHeaderDomDetailMode(selectedSessionTitle), ...getMessagesDom(messages, openRouterApiKeyInput, openApiApiKeyInput, openRouterApiKeyState), ...getChatSendAreaDom(composerValue, models, selectedModelId, usageOverviewEnabled, tokensUsed, tokensMax, composerHeight, composerFontSize, composerFontFamily, composerLineHeight)];
4581
+ }, ...getChatHeaderDomDetailMode(selectedSessionTitle), ...getMessagesDom(messages, openRouterApiKeyInput, openApiApiKeyInput, openRouterApiKeyState, messagesScrollTop), ...getChatSendAreaDom(composerValue, models, selectedModelId, usageOverviewEnabled, tokensUsed, tokensMax, composerHeight, composerFontSize, composerFontFamily, composerLineHeight)];
4263
4582
  };
4264
4583
 
4265
4584
  const getChatHeaderListModeDom = () => {
@@ -4316,7 +4635,7 @@ const getSessionDom = session => {
4316
4635
  }, text('🗑')];
4317
4636
  };
4318
4637
 
4319
- const getChatListDom = (sessions, selectedSessionId) => {
4638
+ const getChatListDom = (sessions, selectedSessionId, chatListScrollTop = 0) => {
4320
4639
  if (sessions.length === 0) {
4321
4640
  return getEmptyChatSessionsDom();
4322
4641
  }
@@ -4324,16 +4643,18 @@ const getChatListDom = (sessions, selectedSessionId) => {
4324
4643
  childCount: sessions.length,
4325
4644
  className: ChatList,
4326
4645
  onClick: HandleClickList,
4646
+ onScroll: HandleChatListScroll,
4647
+ scrollTop: chatListScrollTop,
4327
4648
  type: Div
4328
4649
  }, ...sessions.flatMap(getSessionDom)];
4329
4650
  };
4330
4651
 
4331
- const getChatModeListVirtualDom = (sessions, selectedSessionId, composerValue, models, selectedModelId, usageOverviewEnabled, tokensUsed, tokensMax, composerHeight = 28, composerFontSize = 13, composerFontFamily = 'system-ui', composerLineHeight = 20) => {
4652
+ const getChatModeListVirtualDom = (sessions, selectedSessionId, composerValue, models, selectedModelId, usageOverviewEnabled, tokensUsed, tokensMax, composerHeight = 28, composerFontSize = 13, composerFontFamily = 'system-ui', composerLineHeight = 20, chatListScrollTop = 0) => {
4332
4653
  return [{
4333
4654
  childCount: 3,
4334
4655
  className: mergeClassNames(Viewlet, Chat),
4335
4656
  type: Div
4336
- }, ...getChatHeaderListModeDom(), ...getChatListDom(sessions), ...getChatSendAreaDom(composerValue, models, selectedModelId, usageOverviewEnabled, tokensUsed, tokensMax, composerHeight, composerFontSize, composerFontFamily, composerLineHeight)];
4657
+ }, ...getChatHeaderListModeDom(), ...getChatListDom(sessions, selectedSessionId, chatListScrollTop), ...getChatSendAreaDom(composerValue, models, selectedModelId, usageOverviewEnabled, tokensUsed, tokensMax, composerHeight, composerFontSize, composerFontFamily, composerLineHeight)];
4337
4658
  };
4338
4659
 
4339
4660
  const getChatModeUnsupportedVirtualDom = () => {
@@ -4343,12 +4664,12 @@ const getChatModeUnsupportedVirtualDom = () => {
4343
4664
  }, text(unknownViewMode())];
4344
4665
  };
4345
4666
 
4346
- const getChatVirtualDom = (sessions, selectedSessionId, composerValue, openRouterApiKeyInput, viewMode, models, selectedModelId, usageOverviewEnabled, tokensUsed, tokensMax, openApiApiKeyInput = '', openRouterApiKeyState = 'idle', composerHeight = 28, composerFontSize = 13, composerFontFamily = 'system-ui', composerLineHeight = 20) => {
4667
+ const getChatVirtualDom = (sessions, selectedSessionId, composerValue, openRouterApiKeyInput, viewMode, models, selectedModelId, usageOverviewEnabled, tokensUsed, tokensMax, openApiApiKeyInput = '', openRouterApiKeyState = 'idle', composerHeight = 28, composerFontSize = 13, composerFontFamily = 'system-ui', composerLineHeight = 20, chatListScrollTop = 0, messagesScrollTop = 0) => {
4347
4668
  switch (viewMode) {
4348
4669
  case 'detail':
4349
- return getChatModeDetailVirtualDom(sessions, selectedSessionId, composerValue, openRouterApiKeyInput, openApiApiKeyInput, models, selectedModelId, usageOverviewEnabled, tokensUsed, tokensMax, openRouterApiKeyState, composerHeight, composerFontSize, composerFontFamily, composerLineHeight);
4670
+ return getChatModeDetailVirtualDom(sessions, selectedSessionId, composerValue, openRouterApiKeyInput, openApiApiKeyInput, models, selectedModelId, usageOverviewEnabled, tokensUsed, tokensMax, openRouterApiKeyState, composerHeight, composerFontSize, composerFontFamily, composerLineHeight, messagesScrollTop);
4350
4671
  case 'list':
4351
- return getChatModeListVirtualDom(sessions, selectedSessionId, composerValue, models, selectedModelId, usageOverviewEnabled, tokensUsed, tokensMax, composerHeight, composerFontSize, composerFontFamily, composerLineHeight);
4672
+ return getChatModeListVirtualDom(sessions, selectedSessionId, composerValue, models, selectedModelId, usageOverviewEnabled, tokensUsed, tokensMax, composerHeight, composerFontSize, composerFontFamily, composerLineHeight, chatListScrollTop);
4352
4673
  default:
4353
4674
  return getChatModeUnsupportedVirtualDom();
4354
4675
  }
@@ -4356,12 +4677,14 @@ const getChatVirtualDom = (sessions, selectedSessionId, composerValue, openRoute
4356
4677
 
4357
4678
  const renderItems = (oldState, newState) => {
4358
4679
  const {
4680
+ chatListScrollTop,
4359
4681
  composerFontFamily,
4360
4682
  composerFontSize,
4361
4683
  composerHeight,
4362
4684
  composerLineHeight,
4363
4685
  composerValue,
4364
4686
  initial,
4687
+ messagesScrollTop,
4365
4688
  models,
4366
4689
  openApiApiKeyInput,
4367
4690
  openRouterApiKeyInput,
@@ -4378,7 +4701,7 @@ const renderItems = (oldState, newState) => {
4378
4701
  if (initial) {
4379
4702
  return [SetDom2, uid, []];
4380
4703
  }
4381
- const dom = getChatVirtualDom(sessions, selectedSessionId, composerValue, openRouterApiKeyInput, viewMode, models, selectedModelId, usageOverviewEnabled, tokensUsed, tokensMax, openApiApiKeyInput, openRouterApiKeyState, composerHeight, composerFontSize, composerFontFamily, composerLineHeight);
4704
+ const dom = getChatVirtualDom(sessions, selectedSessionId, composerValue, openRouterApiKeyInput, viewMode, models, selectedModelId, usageOverviewEnabled, tokensUsed, tokensMax, openApiApiKeyInput, openRouterApiKeyState, composerHeight, composerFontSize, composerFontFamily, composerLineHeight, chatListScrollTop, messagesScrollTop);
4382
4705
  return [SetDom2, uid, dom];
4383
4706
  };
4384
4707
 
@@ -4389,6 +4712,14 @@ const renderIncremental = (oldState, newState) => {
4389
4712
  return [SetPatches, newState.uid, patches];
4390
4713
  };
4391
4714
 
4715
+ const renderScrollTop = (oldState, newState) => {
4716
+ const {
4717
+ messagesScrollTop,
4718
+ uid
4719
+ } = newState;
4720
+ return [SetProperty, uid, '.ChatMessages', 'scrollTop', messagesScrollTop];
4721
+ };
4722
+
4392
4723
  const renderValue = (oldState, newState) => {
4393
4724
  const {
4394
4725
  composerValue
@@ -4408,6 +4739,8 @@ const getRenderer = diffType => {
4408
4739
  return renderIncremental;
4409
4740
  case RenderItems:
4410
4741
  return renderItems;
4742
+ case RenderScrollTop:
4743
+ return renderScrollTop;
4411
4744
  case RenderValue:
4412
4745
  return renderValue;
4413
4746
  default:
@@ -4469,6 +4802,12 @@ const renderEventListeners = () => {
4469
4802
  }, {
4470
4803
  name: HandleModelChange,
4471
4804
  params: ['handleModelChange', TargetValue]
4805
+ }, {
4806
+ name: HandleChatListScroll,
4807
+ params: ['handleChatListScroll', 'event.target.scrollTop']
4808
+ }, {
4809
+ name: HandleMessagesScroll,
4810
+ params: ['handleMessagesScroll', 'event.target.scrollTop']
4472
4811
  }, {
4473
4812
  name: HandleFocus,
4474
4813
  params: ['handleInputFocus', TargetName]
@@ -4507,8 +4846,10 @@ const resize = (state, dimensions) => {
4507
4846
 
4508
4847
  const saveState = state => {
4509
4848
  const {
4849
+ chatListScrollTop,
4510
4850
  composerValue,
4511
4851
  height,
4852
+ messagesScrollTop,
4512
4853
  nextMessageId,
4513
4854
  renamingSessionId,
4514
4855
  selectedModelId,
@@ -4519,8 +4860,10 @@ const saveState = state => {
4519
4860
  y
4520
4861
  } = state;
4521
4862
  return {
4863
+ chatListScrollTop,
4522
4864
  composerValue,
4523
4865
  height,
4866
+ messagesScrollTop,
4524
4867
  nextMessageId,
4525
4868
  renamingSessionId,
4526
4869
  selectedModelId,
@@ -4578,6 +4921,7 @@ const commandMap = {
4578
4921
  'Chat.getKeyBindings': getKeyBindings,
4579
4922
  'Chat.getSelectedSessionId': wrapGetter(getSelectedSessionId),
4580
4923
  'Chat.handleChatListContextMenu': handleChatListContextMenu,
4924
+ 'Chat.handleChatListScroll': wrapCommand(handleChatListScroll),
4581
4925
  'Chat.handleClick': wrapCommand(handleClick),
4582
4926
  'Chat.handleClickBack': wrapCommand(handleClickBack),
4583
4927
  'Chat.handleClickClose': handleClickClose,
@@ -4588,6 +4932,7 @@ const commandMap = {
4588
4932
  'Chat.handleInput': wrapCommand(handleInput),
4589
4933
  'Chat.handleInputFocus': wrapCommand(handleInputFocus),
4590
4934
  'Chat.handleKeyDown': wrapCommand(handleKeyDown),
4935
+ 'Chat.handleMessagesScroll': wrapCommand(handleMessagesScroll),
4591
4936
  'Chat.handleModelChange': wrapCommand(handleModelChange),
4592
4937
  'Chat.handleSubmit': wrapCommand(handleSubmit),
4593
4938
  'Chat.initialize': initialize,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lvce-editor/chat-view",
3
- "version": "1.17.0",
3
+ "version": "1.19.0",
4
4
  "description": "Chat View Worker",
5
5
  "repository": {
6
6
  "type": "git",