tlrd-extension 1.0.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.
Files changed (59) hide show
  1. package/.claude/settings.local.json +18 -0
  2. package/.claude/skills/ostovjs-expert/SKILL.md +72 -0
  3. package/README.md +74 -0
  4. package/icons/icon.svg +4 -0
  5. package/icons/icon128.png +0 -0
  6. package/icons/icon16.png +0 -0
  7. package/icons/icon48.png +0 -0
  8. package/manifest.json +26 -0
  9. package/package.json +25 -0
  10. package/postcss.config.js +7 -0
  11. package/src/app/app.html +13 -0
  12. package/src/app/app.ts +50 -0
  13. package/src/app/models/MessageModel.ts +17 -0
  14. package/src/app/models/MessagesCollection.ts +18 -0
  15. package/src/app/models/PageModel.ts +33 -0
  16. package/src/app/models/SettingsModel.ts +61 -0
  17. package/src/app/services/openai.ts +134 -0
  18. package/src/app/styles/chat.css +38 -0
  19. package/src/app/styles/input.css +13 -0
  20. package/src/app/styles/language-selector.css +18 -0
  21. package/src/app/styles/layout.css +49 -0
  22. package/src/app/styles/loader.css +31 -0
  23. package/src/app/styles/message.css +37 -0
  24. package/src/app/styles/scrollbar.css +17 -0
  25. package/src/app/styles/settings.css +55 -0
  26. package/src/app/styles/tldr.css +49 -0
  27. package/src/app/templates/app-header.hbs +28 -0
  28. package/src/app/templates/chat.hbs +22 -0
  29. package/src/app/templates/language-selector.hbs +16 -0
  30. package/src/app/templates/message.hbs +2 -0
  31. package/src/app/templates/settings.hbs +31 -0
  32. package/src/app/templates/tldr.hbs +29 -0
  33. package/src/app/views/AppView.ts +179 -0
  34. package/src/app/views/ChatView.ts +95 -0
  35. package/src/app/views/LanguageSelectorView.ts +52 -0
  36. package/src/app/views/MessageView.ts +33 -0
  37. package/src/app/views/SettingsView.ts +81 -0
  38. package/src/app/views/TldrView.ts +39 -0
  39. package/src/background/background.ts +14 -0
  40. package/src/content/content.ts +49 -0
  41. package/src/popup/popup.css +166 -0
  42. package/src/popup/popup.html +43 -0
  43. package/src/popup/popup.ts +113 -0
  44. package/src/shared/i18n/index.ts +29 -0
  45. package/src/shared/i18n/locales.ts +36 -0
  46. package/src/shared/i18n/translations/ar.ts +36 -0
  47. package/src/shared/i18n/translations/de.ts +36 -0
  48. package/src/shared/i18n/translations/en.ts +36 -0
  49. package/src/shared/i18n/translations/es.ts +36 -0
  50. package/src/shared/i18n/translations/fr.ts +36 -0
  51. package/src/shared/i18n/translations/hi.ts +36 -0
  52. package/src/shared/i18n/translations/ja.ts +36 -0
  53. package/src/shared/i18n/translations/pt.ts +36 -0
  54. package/src/shared/i18n/translations/ru.ts +36 -0
  55. package/src/shared/i18n/translations/zh.ts +36 -0
  56. package/src/types/handlebars.d.ts +4 -0
  57. package/tailwind.config.js +39 -0
  58. package/tsconfig.json +18 -0
  59. package/vite.config.ts +67 -0
@@ -0,0 +1,49 @@
1
+ @layer components {
2
+ .app {
3
+ @apply min-h-screen bg-surface-50 text-surface-900 font-sans;
4
+ }
5
+
6
+ .app__header {
7
+ @apply bg-white border-b border-surface-200 px-6 py-4 flex items-center justify-between sticky top-0 z-10;
8
+ }
9
+
10
+ .app__logo {
11
+ @apply text-xl font-bold text-primary-700 tracking-tight flex items-center gap-2;
12
+ }
13
+
14
+ .app__logo-icon {
15
+ @apply w-7 h-7 text-primary-500;
16
+ }
17
+
18
+ .app__settings-btn {
19
+ @apply p-2 rounded-lg text-surface-500 hover:text-surface-700 hover:bg-surface-100 transition-colors;
20
+ }
21
+
22
+ .app__body {
23
+ @apply max-w-3xl mx-auto px-4 py-8 flex flex-col gap-6;
24
+ }
25
+
26
+ .app__actions {
27
+ @apply flex gap-3 items-center;
28
+ }
29
+
30
+ .btn {
31
+ @apply px-5 py-2.5 rounded-xl font-medium text-sm transition-all duration-200 cursor-pointer inline-flex items-center gap-2;
32
+ }
33
+
34
+ .btn--primary {
35
+ @apply bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 shadow-sm shadow-primary-200;
36
+ }
37
+
38
+ .btn--primary:disabled {
39
+ @apply bg-surface-300 text-surface-500 cursor-not-allowed shadow-none;
40
+ }
41
+
42
+ .btn--secondary {
43
+ @apply bg-white text-surface-700 border border-surface-300 hover:bg-surface-50 hover:border-surface-400;
44
+ }
45
+
46
+ .btn--icon {
47
+ @apply p-2 rounded-lg;
48
+ }
49
+ }
@@ -0,0 +1,31 @@
1
+ @layer components {
2
+ .loader {
3
+ @apply flex items-center justify-center py-8 gap-2;
4
+ }
5
+
6
+ .loader__dot {
7
+ @apply w-2 h-2 rounded-full bg-primary-400;
8
+ animation: loader-bounce 1.4s ease-in-out infinite both;
9
+ }
10
+
11
+ .loader__dot:nth-child(1) {
12
+ animation-delay: -0.32s;
13
+ }
14
+
15
+ .loader__dot:nth-child(2) {
16
+ animation-delay: -0.16s;
17
+ }
18
+
19
+ .loader__dot:nth-child(3) {
20
+ animation-delay: 0s;
21
+ }
22
+ }
23
+
24
+ @keyframes loader-bounce {
25
+ 0%, 80%, 100% {
26
+ transform: scale(0);
27
+ }
28
+ 40% {
29
+ transform: scale(1);
30
+ }
31
+ }
@@ -0,0 +1,37 @@
1
+ @layer components {
2
+ .message {
3
+ @apply flex flex-col gap-1;
4
+ }
5
+
6
+ .message--user {
7
+ @apply items-end;
8
+ }
9
+
10
+ .message--assistant {
11
+ @apply items-start;
12
+ }
13
+
14
+ .message__role {
15
+ @apply text-xs font-medium uppercase tracking-wider;
16
+ }
17
+
18
+ .message--user .message__role {
19
+ @apply text-primary-500;
20
+ }
21
+
22
+ .message--assistant .message__role {
23
+ @apply text-surface-400;
24
+ }
25
+
26
+ .message__bubble {
27
+ @apply text-sm leading-relaxed max-w-[85%] rounded-2xl px-4 py-3 whitespace-pre-wrap;
28
+ }
29
+
30
+ .message--user .message__bubble {
31
+ @apply bg-primary-600 text-white rounded-br-md;
32
+ }
33
+
34
+ .message--assistant .message__bubble {
35
+ @apply bg-surface-100 text-surface-800 rounded-bl-md;
36
+ }
37
+ }
@@ -0,0 +1,17 @@
1
+ @layer components {
2
+ .custom-scrollbar::-webkit-scrollbar {
3
+ width: 6px;
4
+ }
5
+
6
+ .custom-scrollbar::-webkit-scrollbar-track {
7
+ @apply bg-transparent;
8
+ }
9
+
10
+ .custom-scrollbar::-webkit-scrollbar-thumb {
11
+ @apply bg-surface-300 rounded-full;
12
+ }
13
+
14
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover {
15
+ @apply bg-surface-400;
16
+ }
17
+ }
@@ -0,0 +1,55 @@
1
+ @layer components {
2
+ .settings {
3
+ @apply bg-white rounded-2xl border border-surface-200 p-6 shadow-sm flex flex-col gap-4;
4
+ }
5
+
6
+ .settings__title {
7
+ @apply text-base font-semibold text-surface-800 mb-4 flex items-center gap-2;
8
+ }
9
+
10
+ .settings__title-icon {
11
+ @apply w-5 h-5 text-surface-400;
12
+ }
13
+
14
+ .settings__field {
15
+ @apply flex flex-col gap-2;
16
+ }
17
+
18
+ .settings__label {
19
+ @apply text-sm font-medium text-surface-600;
20
+ }
21
+
22
+ .settings__input-wrap {
23
+ @apply flex gap-2;
24
+ }
25
+
26
+ .settings__input {
27
+ @apply flex-1 px-4 py-2.5 rounded-xl border border-surface-300 bg-surface-50 text-sm text-surface-800 placeholder-surface-400
28
+ focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-400 transition-all;
29
+ }
30
+
31
+ .settings__save-btn {
32
+ @apply btn btn--primary;
33
+ }
34
+
35
+ .settings__status {
36
+ @apply text-xs mt-2 font-medium;
37
+ }
38
+
39
+ .settings__status--saved {
40
+ @apply text-green-600;
41
+ }
42
+
43
+ .settings__status--error {
44
+ @apply text-red-500;
45
+ }
46
+
47
+ .settings__link {
48
+ @apply text-xs text-primary-600 hover:text-primary-700 underline;
49
+ }
50
+
51
+ .settings__select {
52
+ @apply w-full px-4 py-2.5 rounded-xl border border-surface-300 bg-surface-50 text-sm text-surface-800
53
+ focus:outline-none focus:ring-2 focus:ring-primary-200 focus:border-primary-400 transition-all cursor-pointer;
54
+ }
55
+ }
@@ -0,0 +1,49 @@
1
+ @layer components {
2
+ .tldr {
3
+ @apply bg-white rounded-2xl border border-surface-200 shadow-sm overflow-hidden;
4
+ }
5
+
6
+ .tldr__header {
7
+ @apply px-6 py-4 border-b border-surface-100 flex items-center justify-between;
8
+ }
9
+
10
+ .tldr__title {
11
+ @apply text-sm font-semibold text-surface-700 uppercase tracking-wider;
12
+ }
13
+
14
+ .tldr__meta {
15
+ @apply flex items-center gap-3;
16
+ }
17
+
18
+ .tldr__page-title {
19
+ @apply text-xs text-surface-400 truncate max-w-xs;
20
+ }
21
+
22
+ .tldr__original-link {
23
+ @apply text-xs text-primary-500 hover:text-primary-700 underline whitespace-nowrap;
24
+ }
25
+
26
+ .tldr__body {
27
+ @apply px-6 py-5;
28
+ }
29
+
30
+ .tldr__content {
31
+ @apply text-sm leading-relaxed text-surface-700 whitespace-pre-wrap;
32
+ }
33
+
34
+ .tldr__empty {
35
+ @apply text-center py-12;
36
+ }
37
+
38
+ .tldr__empty-icon {
39
+ @apply text-4xl mb-3;
40
+ }
41
+
42
+ .tldr__empty-text {
43
+ @apply text-sm text-surface-400;
44
+ }
45
+
46
+ .tldr__error {
47
+ @apply text-sm text-red-500 bg-red-50 rounded-xl px-4 py-3;
48
+ }
49
+ }
@@ -0,0 +1,28 @@
1
+ <header class="app__header">
2
+ <div class="app__logo">
3
+ <svg class="app__logo-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
4
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
5
+ </svg>
6
+ TLDR
7
+ </div>
8
+ <div class="app__actions">
9
+ <div data-region="lang-selector"></div>
10
+ <button class="btn btn--primary" data-action="summarize" {{#unless hasApiKey}}disabled{{/unless}}>
11
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width:16px;height:16px;">
12
+ <path stroke-linecap="round" stroke-linejoin="round" d="m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z" />
13
+ </svg>
14
+ {{t "app.btn.summarize"}}
15
+ </button>
16
+ <button class="app__settings-btn" data-action="toggle-settings" title="{{t "popup.settings"}}">
17
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width:20px;height:20px;">
18
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
19
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
20
+ </svg>
21
+ </button>
22
+ </div>
23
+ </header>
24
+ <main class="app__body">
25
+ <div data-region="settings" {{#unless settingsVisible}}style="display:none"{{/unless}}></div>
26
+ <div data-region="tldr"></div>
27
+ <div data-region="chat"></div>
28
+ </main>
@@ -0,0 +1,22 @@
1
+ <div class="chat__header">
2
+ <span class="chat__title">{{t "app.chat.title"}}</span>
3
+ </div>
4
+ <div class="chat__messages custom-scrollbar" data-region="messages">
5
+ {{#unless hasMessages}}
6
+ <div class="chat__empty">{{t "app.chat.empty"}}</div>
7
+ {{/unless}}
8
+ </div>
9
+ <div class="chat__input-wrap">
10
+ <textarea
11
+ class="chat__input"
12
+ placeholder="{{t "app.chat.placeholder"}}"
13
+ rows="1"
14
+ data-input="question"
15
+ {{#if disabled}}disabled{{/if}}
16
+ ></textarea>
17
+ <button class="chat__send-btn" data-action="send" {{#if disabled}}disabled{{/if}}>
18
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" style="width:18px;height:18px;">
19
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
20
+ </svg>
21
+ </button>
22
+ </div>
@@ -0,0 +1,16 @@
1
+ <label class="lang-selector__field">
2
+ <span class="lang-selector__label">{{t "app.lang.summary"}}</span>
3
+ <select class="lang-selector__select" data-action="change-summary-lang">
4
+ {{#each summaryLanguages}}
5
+ <option value="{{this.code}}" {{#if this.selected}}selected{{/if}}>{{this.nativeLabel}}</option>
6
+ {{/each}}
7
+ </select>
8
+ </label>
9
+ <label class="lang-selector__field">
10
+ <span class="lang-selector__label">{{t "app.lang.ui"}}</span>
11
+ <select class="lang-selector__select" data-action="change-ui-lang">
12
+ {{#each uiLanguages}}
13
+ <option value="{{this.code}}" {{#if this.selected}}selected{{/if}}>{{this.nativeLabel}}</option>
14
+ {{/each}}
15
+ </select>
16
+ </label>
@@ -0,0 +1,2 @@
1
+ <span class="message__role">{{roleLabel}}</span>
2
+ <div class="message__bubble">{{content}}</div>
@@ -0,0 +1,31 @@
1
+ <div class="settings__title">
2
+ <svg class="settings__title-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
3
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z" />
4
+ </svg>
5
+ {{t "app.settings.title"}}
6
+ </div>
7
+ <div class="settings__field">
8
+ <label class="settings__label" for="api-key">{{t "app.settings.label"}}</label>
9
+ <div class="settings__input-wrap">
10
+ <input
11
+ class="settings__input"
12
+ id="api-key"
13
+ type="password"
14
+ placeholder="sk-..."
15
+ value="{{apiKey}}"
16
+ />
17
+ <button class="settings__save-btn" data-action="save">{{t "app.settings.save"}}</button>
18
+ </div>
19
+ <a class="settings__link" href="https://platform.openai.com/api-keys" target="_blank" rel="noopener">{{t "app.settings.link"}}</a>
20
+ {{#if status}}
21
+ <div class="settings__status settings__status--{{statusType}}">{{status}}</div>
22
+ {{/if}}
23
+ </div>
24
+ <div class="settings__field">
25
+ <label class="settings__label" for="model-select">{{t "app.settings.model"}}</label>
26
+ <select class="settings__select" id="model-select" data-action="change-model">
27
+ {{#each models}}
28
+ <option value="{{this.id}}" {{#if this.selected}}selected{{/if}}>{{this.label}}</option>
29
+ {{/each}}
30
+ </select>
31
+ </div>
@@ -0,0 +1,29 @@
1
+ <div class="tldr__header">
2
+ <span class="tldr__title">{{t "app.tldr.title"}}</span>
3
+ <div class="tldr__meta">
4
+ {{#if pageTitle}}
5
+ <span class="tldr__page-title" title="{{pageTitle}}">{{pageTitle}}</span>
6
+ {{/if}}
7
+ {{#if pageUrl}}
8
+ <a class="tldr__original-link" href="{{pageUrl}}" target="_blank" rel="noopener">{{t "app.tldr.original"}}</a>
9
+ {{/if}}
10
+ </div>
11
+ </div>
12
+ <div class="tldr__body">
13
+ {{#if loading}}
14
+ <div class="loader">
15
+ <div class="loader__dot"></div>
16
+ <div class="loader__dot"></div>
17
+ <div class="loader__dot"></div>
18
+ </div>
19
+ {{else if error}}
20
+ <div class="tldr__error">{{error}}</div>
21
+ {{else if tldr}}
22
+ <div class="tldr__content">{{tldr}}</div>
23
+ {{else}}
24
+ <div class="tldr__empty">
25
+ <div class="tldr__empty-icon">&#128196;</div>
26
+ <div class="tldr__empty-text">{{t "app.tldr.empty"}}</div>
27
+ </div>
28
+ {{/if}}
29
+ </div>
@@ -0,0 +1,179 @@
1
+ import { View } from 'ostovjs';
2
+ import template from '../templates/app-header.hbs';
3
+ import SettingsView from './SettingsView';
4
+ import TldrView from './TldrView';
5
+ import ChatView from './ChatView';
6
+ import LanguageSelectorView from './LanguageSelectorView';
7
+ import OpenAIService from '../services/openai';
8
+ import { setLocale } from '../../shared/i18n';
9
+ import type { SupportedLocale } from '../../shared/i18n/locales';
10
+ import type SettingsModel from '../models/SettingsModel';
11
+ import type PageModel from '../models/PageModel';
12
+ import type MessagesCollection from '../models/MessagesCollection';
13
+
14
+ interface AppViewOptions {
15
+ settingsModel: SettingsModel;
16
+ pageModel: PageModel;
17
+ messagesCollection: MessagesCollection;
18
+ }
19
+
20
+ class AppView extends View {
21
+ private settingsModel!: SettingsModel;
22
+ declare pageModel: PageModel;
23
+ private messagesCollection!: MessagesCollection;
24
+ private openai!: OpenAIService;
25
+ private settingsView!: SettingsView;
26
+ private tldrView!: TldrView;
27
+ private chatView!: ChatView;
28
+ private langSelectorView!: LanguageSelectorView;
29
+ private _settingsVisible = false;
30
+
31
+ constructor(options: AppViewOptions) {
32
+ super({
33
+ ...options,
34
+ el: '#app',
35
+ events: {
36
+ 'click [data-action=summarize]': 'onSummarize',
37
+ 'click [data-action=toggle-settings]': 'onToggleSettings',
38
+ },
39
+ });
40
+ }
41
+
42
+ initialize(options: AppViewOptions) {
43
+ this.settingsModel = options.settingsModel;
44
+ this.pageModel = options.pageModel;
45
+ this.messagesCollection = options.messagesCollection;
46
+
47
+ this.openai = new OpenAIService(this.settingsModel);
48
+
49
+ this.settingsView = new SettingsView({ model: this.settingsModel });
50
+ this.tldrView = new TldrView({ model: this.pageModel });
51
+ this.chatView = new ChatView({ collection: this.messagesCollection });
52
+ this.langSelectorView = new LanguageSelectorView({ model: this.settingsModel });
53
+
54
+ this.listenTo(this.chatView, 'ask', this.onAskQuestion);
55
+ this.listenTo(this.settingsModel, 'saved', this.onSettingsSaved);
56
+ this.listenTo(this.langSelectorView, 'locale-changed', this.onLocaleChanged);
57
+
58
+ this._settingsVisible = !this.settingsModel.hasApiKey();
59
+ }
60
+
61
+ render() {
62
+ this._settingsVisible = !this.settingsModel.hasApiKey();
63
+
64
+ const el = this.el as HTMLElement;
65
+ el.innerHTML = template({
66
+ hasApiKey: this.settingsModel.hasApiKey(),
67
+ settingsVisible: this._settingsVisible,
68
+ hasTldr: this.pageModel.hasTldr(),
69
+ });
70
+
71
+ el.querySelector('[data-region=settings]')!.appendChild(
72
+ this.settingsView.render().el as HTMLElement
73
+ );
74
+ el.querySelector('[data-region=tldr]')!.appendChild(
75
+ this.tldrView.render().el as HTMLElement
76
+ );
77
+ el.querySelector('[data-region=chat]')!.appendChild(
78
+ this.chatView.render().el as HTMLElement
79
+ );
80
+ el.querySelector('[data-region=lang-selector]')!.appendChild(
81
+ this.langSelectorView.render().el as HTMLElement
82
+ );
83
+
84
+ return this;
85
+ }
86
+
87
+ onSettingsSaved() {
88
+ const el = this.el as HTMLElement;
89
+ const btn = el.querySelector<HTMLButtonElement>('[data-action=summarize]');
90
+ if (btn) btn.disabled = !this.settingsModel.hasApiKey();
91
+ }
92
+
93
+ onLocaleChanged(locale: string) {
94
+ setLocale(locale as SupportedLocale);
95
+ this.render();
96
+ }
97
+
98
+ onToggleSettings() {
99
+ this._settingsVisible = !this._settingsVisible;
100
+ const el = this.el as HTMLElement;
101
+ const settingsRegion = el.querySelector<HTMLElement>('[data-region=settings]')!;
102
+ settingsRegion.style.display = this._settingsVisible ? '' : 'none';
103
+ }
104
+
105
+ async onSummarize() {
106
+ const el = this.el as HTMLElement;
107
+
108
+ if (!this.settingsModel.hasApiKey()) {
109
+ this._settingsVisible = true;
110
+ el.querySelector<HTMLElement>('[data-region=settings]')!.style.display = '';
111
+ return;
112
+ }
113
+
114
+ if (this.pageModel.get('loading')) return;
115
+
116
+ if (!this.pageModel.hasContent()) {
117
+ this.pageModel.set('error', 'No page content available. Open this from the extension popup.');
118
+ return;
119
+ }
120
+
121
+ this.pageModel.set({ loading: true, error: '', tldr: '' });
122
+
123
+ try {
124
+ const language = this.settingsModel.get('summaryLanguage');
125
+ const tldr = await this.openai.generateTldrStream(
126
+ this.pageModel.get('content'),
127
+ this.pageModel.get('title'),
128
+ language,
129
+ (text) => {
130
+ if (this.pageModel.get('loading')) {
131
+ this.pageModel.set({ loading: false, tldr: text }, { silent: true });
132
+ this.tldrView.render();
133
+ } else {
134
+ this.tldrView.updateContent(text);
135
+ }
136
+ }
137
+ );
138
+ this.pageModel.set({ tldr, loading: false });
139
+ } catch (err) {
140
+ const message = err instanceof Error ? err.message : 'Unknown error';
141
+ this.pageModel.set({ error: message, loading: false });
142
+ }
143
+ }
144
+
145
+ async onAskQuestion(question: string) {
146
+ this.messagesCollection.add({ role: 'user', content: question });
147
+ this.chatView.setDisabled(true);
148
+
149
+ const assistantMsg = this.messagesCollection.add({ role: 'assistant', content: '...' });
150
+
151
+ try {
152
+ const language = this.settingsModel.get('summaryLanguage');
153
+ const messagesForApi = this.messagesCollection.toOpenAIFormat().slice(0, -1);
154
+
155
+ const answer = await this.openai.askFollowUpStream(
156
+ messagesForApi,
157
+ this.pageModel.get('content'),
158
+ this.pageModel.get('tldr'),
159
+ language,
160
+ (text) => {
161
+ (assistantMsg as any).set('content', text, { silent: true });
162
+ const lastView = this.chatView.getLastMessageView();
163
+ if (lastView) lastView.updateContent(text);
164
+ this.chatView.scrollToBottom();
165
+ }
166
+ );
167
+ (assistantMsg as any).set('content', answer);
168
+ } catch (err) {
169
+ const message = err instanceof Error ? err.message : 'Unknown error';
170
+ (assistantMsg as any).set('content', `Error: ${message}`);
171
+ }
172
+
173
+ this.chatView.setDisabled(false);
174
+ const el = this.el as HTMLElement;
175
+ el.querySelector<HTMLTextAreaElement>('[data-input=question]')?.focus();
176
+ }
177
+ }
178
+
179
+ export default AppView;
@@ -0,0 +1,95 @@
1
+ import { View } from 'ostovjs';
2
+ import template from '../templates/chat.hbs';
3
+ import MessageView from './MessageView';
4
+ import type MessageModel from '../models/MessageModel';
5
+ import type MessagesCollection from '../models/MessagesCollection';
6
+
7
+ class ChatView extends View {
8
+ declare collection: MessagesCollection;
9
+ private _disabled = false;
10
+ private _lastMessageView: MessageView | null = null;
11
+
12
+ constructor(options: { collection: MessagesCollection }) {
13
+ super({
14
+ ...options,
15
+ tagName: 'div',
16
+ className: 'chat',
17
+ events: {
18
+ 'click [data-action=send]': 'onSend',
19
+ 'keydown [data-input=question]': 'onKeydown',
20
+ },
21
+ });
22
+ }
23
+
24
+ initialize() {
25
+ this.listenTo(this.collection, 'add', this.onMessageAdded);
26
+ }
27
+
28
+ render() {
29
+ const el = this.el as HTMLElement;
30
+ el.innerHTML = template({
31
+ hasMessages: this.collection.length > 0,
32
+ disabled: this._disabled,
33
+ });
34
+
35
+ const messagesEl = el.querySelector('[data-region=messages]')!;
36
+ this.collection.models.forEach((msg: MessageModel) => {
37
+ const view = new MessageView({ model: msg });
38
+ messagesEl.appendChild(view.render().el as HTMLElement);
39
+ });
40
+
41
+ return this;
42
+ }
43
+
44
+ onMessageAdded(msg: MessageModel) {
45
+ const el = this.el as HTMLElement;
46
+ const messagesEl = el.querySelector('[data-region=messages]')!;
47
+ const emptyEl = messagesEl.querySelector('.chat__empty');
48
+ if (emptyEl) emptyEl.remove();
49
+
50
+ const view = new MessageView({ model: msg });
51
+ messagesEl.appendChild(view.render().el as HTMLElement);
52
+ messagesEl.scrollTop = messagesEl.scrollHeight;
53
+ this._lastMessageView = view;
54
+ }
55
+
56
+ getLastMessageView(): MessageView | null {
57
+ return this._lastMessageView;
58
+ }
59
+
60
+ scrollToBottom() {
61
+ const el = this.el as HTMLElement;
62
+ const messagesEl = el.querySelector('[data-region=messages]');
63
+ if (messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight;
64
+ }
65
+
66
+ onKeydown(e: KeyboardEvent) {
67
+ if (e.key === 'Enter' && !e.shiftKey) {
68
+ e.preventDefault();
69
+ this.onSend();
70
+ }
71
+ }
72
+
73
+ onSend() {
74
+ if (this._disabled) return;
75
+
76
+ const el = this.el as HTMLElement;
77
+ const input = el.querySelector<HTMLTextAreaElement>('[data-input=question]');
78
+ const question = input?.value.trim();
79
+ if (!question) return;
80
+
81
+ if (input) input.value = '';
82
+ this.trigger('ask', question);
83
+ }
84
+
85
+ setDisabled(disabled: boolean) {
86
+ this._disabled = disabled;
87
+ const el = this.el as HTMLElement;
88
+ const input = el.querySelector<HTMLTextAreaElement>('[data-input=question]');
89
+ const btn = el.querySelector<HTMLButtonElement>('[data-action=send]');
90
+ if (input) input.disabled = disabled;
91
+ if (btn) btn.disabled = disabled;
92
+ }
93
+ }
94
+
95
+ export default ChatView;