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.
- package/.claude/settings.local.json +18 -0
- package/.claude/skills/ostovjs-expert/SKILL.md +72 -0
- package/README.md +74 -0
- package/icons/icon.svg +4 -0
- package/icons/icon128.png +0 -0
- package/icons/icon16.png +0 -0
- package/icons/icon48.png +0 -0
- package/manifest.json +26 -0
- package/package.json +25 -0
- package/postcss.config.js +7 -0
- package/src/app/app.html +13 -0
- package/src/app/app.ts +50 -0
- package/src/app/models/MessageModel.ts +17 -0
- package/src/app/models/MessagesCollection.ts +18 -0
- package/src/app/models/PageModel.ts +33 -0
- package/src/app/models/SettingsModel.ts +61 -0
- package/src/app/services/openai.ts +134 -0
- package/src/app/styles/chat.css +38 -0
- package/src/app/styles/input.css +13 -0
- package/src/app/styles/language-selector.css +18 -0
- package/src/app/styles/layout.css +49 -0
- package/src/app/styles/loader.css +31 -0
- package/src/app/styles/message.css +37 -0
- package/src/app/styles/scrollbar.css +17 -0
- package/src/app/styles/settings.css +55 -0
- package/src/app/styles/tldr.css +49 -0
- package/src/app/templates/app-header.hbs +28 -0
- package/src/app/templates/chat.hbs +22 -0
- package/src/app/templates/language-selector.hbs +16 -0
- package/src/app/templates/message.hbs +2 -0
- package/src/app/templates/settings.hbs +31 -0
- package/src/app/templates/tldr.hbs +29 -0
- package/src/app/views/AppView.ts +179 -0
- package/src/app/views/ChatView.ts +95 -0
- package/src/app/views/LanguageSelectorView.ts +52 -0
- package/src/app/views/MessageView.ts +33 -0
- package/src/app/views/SettingsView.ts +81 -0
- package/src/app/views/TldrView.ts +39 -0
- package/src/background/background.ts +14 -0
- package/src/content/content.ts +49 -0
- package/src/popup/popup.css +166 -0
- package/src/popup/popup.html +43 -0
- package/src/popup/popup.ts +113 -0
- package/src/shared/i18n/index.ts +29 -0
- package/src/shared/i18n/locales.ts +36 -0
- package/src/shared/i18n/translations/ar.ts +36 -0
- package/src/shared/i18n/translations/de.ts +36 -0
- package/src/shared/i18n/translations/en.ts +36 -0
- package/src/shared/i18n/translations/es.ts +36 -0
- package/src/shared/i18n/translations/fr.ts +36 -0
- package/src/shared/i18n/translations/hi.ts +36 -0
- package/src/shared/i18n/translations/ja.ts +36 -0
- package/src/shared/i18n/translations/pt.ts +36 -0
- package/src/shared/i18n/translations/ru.ts +36 -0
- package/src/shared/i18n/translations/zh.ts +36 -0
- package/src/types/handlebars.d.ts +4 -0
- package/tailwind.config.js +39 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +67 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(firecrawl scrape:*)",
|
|
5
|
+
"Bash(npx firecrawl:*)",
|
|
6
|
+
"Bash(npm list:*)",
|
|
7
|
+
"WebFetch(domain:ostovjs.org)",
|
|
8
|
+
"Bash(npm install:*)",
|
|
9
|
+
"Bash(npm run:*)",
|
|
10
|
+
"Bash(ls src/app/templates/*.js)",
|
|
11
|
+
"Bash(npm ls:*)",
|
|
12
|
+
"Bash(npm search:*)",
|
|
13
|
+
"Bash(grep 'app.settings.model' src/shared/i18n/translations/*.ts)",
|
|
14
|
+
"WebFetch(domain:platform.openai.com)",
|
|
15
|
+
"WebSearch"
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ostovjs-expert
|
|
3
|
+
description: Expert skill for building applications with OstovJS (Backbone-like framework with Models, Views, Collections, Events). Use when working with OstovJS, generating components, explaining architecture, handling events, collections, or templates.
|
|
4
|
+
compatibility: Designed for Claude Code and agent environments working with JavaScript/TypeScript projects.
|
|
5
|
+
metadata:
|
|
6
|
+
author: ostovjs
|
|
7
|
+
version: "1.0.0"
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# OstovJS Expert
|
|
11
|
+
|
|
12
|
+
You are an expert in OstovJS — a lightweight framework inspired by Backbone.js, based on Models, Views, Collections, and Events.
|
|
13
|
+
|
|
14
|
+
This skill is activated when the user is working with OstovJS or asks about architecture, code generation, or explanations related to it.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 🧠 Core Principles
|
|
19
|
+
|
|
20
|
+
- Separate responsibilities:
|
|
21
|
+
- Models → data and business logic
|
|
22
|
+
- Views → UI rendering
|
|
23
|
+
- Collections → lists of models
|
|
24
|
+
- Events → communication layer
|
|
25
|
+
|
|
26
|
+
- Prefer minimalism and clarity
|
|
27
|
+
- Avoid overengineering
|
|
28
|
+
- Keep code readable and structured
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## ⚙️ Architecture Rules
|
|
33
|
+
|
|
34
|
+
- Do NOT mix business logic into Views
|
|
35
|
+
- Do NOT place UI logic inside Models
|
|
36
|
+
- Use events instead of direct coupling
|
|
37
|
+
- Prefer composition over inheritance
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 🧩 Code Generation
|
|
42
|
+
|
|
43
|
+
When generating code:
|
|
44
|
+
|
|
45
|
+
1. Use TypeScript by default
|
|
46
|
+
2. Always generate full working examples (not snippets)
|
|
47
|
+
3. Include imports and setup
|
|
48
|
+
4. Use realistic naming:
|
|
49
|
+
- `TodoModel`
|
|
50
|
+
- `TodoView`
|
|
51
|
+
- `TodoCollection`
|
|
52
|
+
5. Keep structure simple and idiomatic
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## 🔄 Events
|
|
57
|
+
|
|
58
|
+
Use event-driven architecture:
|
|
59
|
+
|
|
60
|
+
- Prefer loose coupling
|
|
61
|
+
- Show event flow clearly
|
|
62
|
+
- Use patterns like:
|
|
63
|
+
- `on`
|
|
64
|
+
- `emit`
|
|
65
|
+
- `listenTo`
|
|
66
|
+
|
|
67
|
+
### Example pattern
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
model.on("change", () => {
|
|
71
|
+
view.render()
|
|
72
|
+
})
|
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# TLDR - AI Page Summarizer
|
|
2
|
+
|
|
3
|
+
A Chrome extension that generates concise TLDR summaries of any web page using OpenAI. Ask follow-up questions in a dedicated tab with full chat interface.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Instant TLDR** - One-click page summarization with streaming output
|
|
8
|
+
- **Follow-up Chat** - Ask questions about the page content in a conversational interface
|
|
9
|
+
- **Multi-language** - Summarize in 10 languages: English, Chinese, Spanish, Hindi, Arabic, French, Portuguese, Russian, Japanese, German
|
|
10
|
+
- **Localized UI** - Full interface translation for all supported languages
|
|
11
|
+
- **Model Selection** - Choose from GPT-5.4, GPT-5.4 Mini, GPT-5.4 Nano, GPT-4.1 family, and o3-mini
|
|
12
|
+
- **Streaming** - Responses appear in real-time as they're generated
|
|
13
|
+
- **Auto-summarize** - Opens a dedicated tab and starts summarizing immediately
|
|
14
|
+
|
|
15
|
+
<img width="1724" height="984" alt="Screenshot 2026-03-21 at 22 25 19" src="https://github.com/user-attachments/assets/4ba9c577-ccb8-4d52-a662-92cb3ccdfca4" />
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
## Architecture
|
|
19
|
+
|
|
20
|
+
Built with [OstovJS](https://ostovjs.org/) (Backbone-like MVC framework), Handlebars templates (precompiled at build time), and Tailwind CSS with BEM methodology.
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
src/
|
|
24
|
+
app/ # Main application (opens in new tab)
|
|
25
|
+
models/ # OstovJS Models (Settings, Page, Message, Collection)
|
|
26
|
+
views/ # OstovJS Views (App, Settings, TLDR, Chat, Message, LanguageSelector)
|
|
27
|
+
templates/ # Handlebars templates (.hbs)
|
|
28
|
+
styles/ # Tailwind CSS + BEM (@apply approach)
|
|
29
|
+
services/ # OpenAI API service with streaming
|
|
30
|
+
popup/ # Extension popup (API key + summarize button)
|
|
31
|
+
background/ # Service worker
|
|
32
|
+
content/ # Content script (page text extraction)
|
|
33
|
+
shared/
|
|
34
|
+
i18n/ # Internationalization (10 languages)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Setup
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Install dependencies
|
|
41
|
+
npm install
|
|
42
|
+
|
|
43
|
+
# Build
|
|
44
|
+
npm run build
|
|
45
|
+
|
|
46
|
+
# Watch mode (rebuild on changes)
|
|
47
|
+
npm run dev
|
|
48
|
+
|
|
49
|
+
# Type check
|
|
50
|
+
npm run typecheck
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Install in Chrome
|
|
54
|
+
|
|
55
|
+
1. Run `npm run build`
|
|
56
|
+
2. Open `chrome://extensions/`
|
|
57
|
+
3. Enable **Developer mode**
|
|
58
|
+
4. Click **Load unpacked** and select the `dist/` folder
|
|
59
|
+
5. Click the extension icon, enter your [OpenAI API key](https://platform.openai.com/api-keys)
|
|
60
|
+
6. Navigate to any page and click **Summarize**
|
|
61
|
+
|
|
62
|
+
## Tech Stack
|
|
63
|
+
|
|
64
|
+
- **Framework** - [OstovJS](https://ostovjs.org/) (Models, Views, Collections, Events)
|
|
65
|
+
- **Templates** - Handlebars (precompiled, CSP-safe)
|
|
66
|
+
- **Styling** - Tailwind CSS 3 + BEM + `@apply`
|
|
67
|
+
- **Language** - TypeScript
|
|
68
|
+
- **Bundler** - Vite
|
|
69
|
+
- **API** - OpenAI Chat Completions (streaming)
|
|
70
|
+
- **Platform** - Chrome Extension Manifest V3
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT
|
package/icons/icon.svg
ADDED
|
Binary file
|
package/icons/icon16.png
ADDED
|
Binary file
|
package/icons/icon48.png
ADDED
|
Binary file
|
package/manifest.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "TLDR - Page Summarizer",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Get a TLDR summary of any page using OpenAI. Ask follow-up questions in a dedicated tab.",
|
|
6
|
+
"permissions": ["activeTab", "storage", "scripting"],
|
|
7
|
+
"action": {
|
|
8
|
+
"default_popup": "src/popup/popup.html",
|
|
9
|
+
"default_icon": {
|
|
10
|
+
"16": "icons/icon16.png",
|
|
11
|
+
"48": "icons/icon48.png",
|
|
12
|
+
"128": "icons/icon128.png"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"background": {
|
|
16
|
+
"service_worker": "background.js"
|
|
17
|
+
},
|
|
18
|
+
"icons": {
|
|
19
|
+
"16": "icons/icon16.png",
|
|
20
|
+
"48": "icons/icon48.png",
|
|
21
|
+
"128": "icons/icon128.png"
|
|
22
|
+
},
|
|
23
|
+
"content_security_policy": {
|
|
24
|
+
"extension_pages": "script-src 'self'; object-src 'self'"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tlrd-extension",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"author": "",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "vite build",
|
|
10
|
+
"dev": "vite build --watch",
|
|
11
|
+
"typecheck": "tsc --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"handlebars": "^4.7.8",
|
|
15
|
+
"ostovjs": "^1.7.8"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/chrome": "^0.1.38",
|
|
19
|
+
"autoprefixer": "^10.4.27",
|
|
20
|
+
"postcss": "^8.5.8",
|
|
21
|
+
"tailwindcss": "^3.4.19",
|
|
22
|
+
"typescript": "^5.9.3",
|
|
23
|
+
"vite": "^8.0.1"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/app/app.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>TLDR - Page Summary</title>
|
|
7
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app" class="app"></div>
|
|
11
|
+
<script type="module" src="app.ts"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/src/app/app.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import Handlebars from 'handlebars/runtime';
|
|
2
|
+
import { t, setLocale } from '../shared/i18n';
|
|
3
|
+
import type { SupportedLocale } from '../shared/i18n/locales';
|
|
4
|
+
import SettingsModel from './models/SettingsModel';
|
|
5
|
+
import PageModel from './models/PageModel';
|
|
6
|
+
import MessagesCollection from './models/MessagesCollection';
|
|
7
|
+
import AppView from './views/AppView';
|
|
8
|
+
import './styles/input.css';
|
|
9
|
+
|
|
10
|
+
Handlebars.registerHelper('t', (key: string) => t(key));
|
|
11
|
+
|
|
12
|
+
const settingsModel = new SettingsModel();
|
|
13
|
+
const pageModel = new PageModel();
|
|
14
|
+
const messagesCollection = new MessagesCollection();
|
|
15
|
+
|
|
16
|
+
const params = new URLSearchParams(window.location.search);
|
|
17
|
+
const pageTitle = params.get('title') || '';
|
|
18
|
+
const pageUrl = params.get('url') || '';
|
|
19
|
+
|
|
20
|
+
pageModel.set({ title: pageTitle, url: pageUrl }, { silent: true });
|
|
21
|
+
|
|
22
|
+
let appView: AppView | null = null;
|
|
23
|
+
let autoSummarized = false;
|
|
24
|
+
|
|
25
|
+
function tryAutoSummarize() {
|
|
26
|
+
if (autoSummarized || !appView) return;
|
|
27
|
+
if (!settingsModel.hasApiKey() || !pageModel.hasContent()) return;
|
|
28
|
+
autoSummarized = true;
|
|
29
|
+
appView.onSummarize();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
chrome.storage.session.get('pendingPageContent').then((data) => {
|
|
33
|
+
if (data.pendingPageContent) {
|
|
34
|
+
pageModel.set('content', data.pendingPageContent);
|
|
35
|
+
chrome.storage.session.remove('pendingPageContent');
|
|
36
|
+
tryAutoSummarize();
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
settingsModel.on('loaded', () => {
|
|
41
|
+
setLocale(settingsModel.get('uiLanguage') as SupportedLocale);
|
|
42
|
+
|
|
43
|
+
appView = new AppView({
|
|
44
|
+
settingsModel,
|
|
45
|
+
pageModel,
|
|
46
|
+
messagesCollection,
|
|
47
|
+
});
|
|
48
|
+
appView.render();
|
|
49
|
+
tryAutoSummarize();
|
|
50
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Model } from 'ostovjs';
|
|
2
|
+
|
|
3
|
+
export interface MessageAttributes {
|
|
4
|
+
role: 'user' | 'assistant' | 'system';
|
|
5
|
+
content: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
class MessageModel extends Model<MessageAttributes> {
|
|
9
|
+
defaults() {
|
|
10
|
+
return {
|
|
11
|
+
role: 'user' as const,
|
|
12
|
+
content: '',
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default MessageModel;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Collection } from 'ostovjs';
|
|
2
|
+
import MessageModel, { type MessageAttributes } from './MessageModel';
|
|
3
|
+
|
|
4
|
+
class MessagesCollection extends Collection<MessageModel> {
|
|
5
|
+
constructor(models?: MessageModel[], options?: Record<string, unknown>) {
|
|
6
|
+
super(models, options);
|
|
7
|
+
this.model = MessageModel;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
toOpenAIFormat(): MessageAttributes[] {
|
|
11
|
+
return this.models.map((msg) => ({
|
|
12
|
+
role: msg.get('role'),
|
|
13
|
+
content: msg.get('content'),
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default MessagesCollection;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Model } from 'ostovjs';
|
|
2
|
+
|
|
3
|
+
interface PageAttributes {
|
|
4
|
+
title: string;
|
|
5
|
+
url: string;
|
|
6
|
+
content: string;
|
|
7
|
+
tldr: string;
|
|
8
|
+
loading: boolean;
|
|
9
|
+
error: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
class PageModel extends Model<PageAttributes> {
|
|
13
|
+
defaults() {
|
|
14
|
+
return {
|
|
15
|
+
title: '',
|
|
16
|
+
url: '',
|
|
17
|
+
content: '',
|
|
18
|
+
tldr: '',
|
|
19
|
+
loading: false,
|
|
20
|
+
error: '',
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
hasContent(): boolean {
|
|
25
|
+
return Boolean(this.get('content'));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
hasTldr(): boolean {
|
|
29
|
+
return Boolean(this.get('tldr'));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default PageModel;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Model } from 'ostovjs';
|
|
2
|
+
import type { SupportedLocale, SummaryLanguage } from '../../shared/i18n/locales';
|
|
3
|
+
|
|
4
|
+
interface SettingsAttributes {
|
|
5
|
+
apiKey: string;
|
|
6
|
+
model: string;
|
|
7
|
+
summaryLanguage: SummaryLanguage;
|
|
8
|
+
uiLanguage: SupportedLocale;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const AVAILABLE_MODELS = [
|
|
12
|
+
{ id: 'gpt-5.4', label: 'GPT-5.4' },
|
|
13
|
+
{ id: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' },
|
|
14
|
+
{ id: 'gpt-5.4-nano', label: 'GPT-5.4 Nano' },
|
|
15
|
+
{ id: 'gpt-4.1', label: 'GPT-4.1' },
|
|
16
|
+
{ id: 'gpt-4.1-mini', label: 'GPT-4.1 Mini' },
|
|
17
|
+
{ id: 'gpt-4.1-nano', label: 'GPT-4.1 Nano' },
|
|
18
|
+
{ id: 'o3-mini', label: 'o3-mini' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
class SettingsModel extends Model<SettingsAttributes> {
|
|
22
|
+
defaults() {
|
|
23
|
+
return {
|
|
24
|
+
apiKey: '',
|
|
25
|
+
model: 'gpt-5.4-mini',
|
|
26
|
+
summaryLanguage: 'original' as SummaryLanguage,
|
|
27
|
+
uiLanguage: 'en' as SupportedLocale,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
initialize() {
|
|
32
|
+
this.load();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
load(): void {
|
|
36
|
+
chrome.storage.local.get(['apiKey', 'model', 'summaryLanguage', 'uiLanguage']).then((data) => {
|
|
37
|
+
if (data.apiKey) this.set('apiKey', data.apiKey as string, { silent: true });
|
|
38
|
+
if (data.model) this.set('model', data.model as string, { silent: true });
|
|
39
|
+
if (data.summaryLanguage) this.set('summaryLanguage', data.summaryLanguage as SummaryLanguage, { silent: true });
|
|
40
|
+
if (data.uiLanguage) this.set('uiLanguage', data.uiLanguage as SupportedLocale, { silent: true });
|
|
41
|
+
this.trigger('loaded');
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
persist(): void {
|
|
46
|
+
chrome.storage.local.set({
|
|
47
|
+
apiKey: this.get('apiKey'),
|
|
48
|
+
model: this.get('model'),
|
|
49
|
+
summaryLanguage: this.get('summaryLanguage'),
|
|
50
|
+
uiLanguage: this.get('uiLanguage'),
|
|
51
|
+
}).then(() => {
|
|
52
|
+
this.trigger('saved');
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
hasApiKey(): boolean {
|
|
57
|
+
return Boolean(this.get('apiKey'));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default SettingsModel;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type SettingsModel from '../models/SettingsModel';
|
|
2
|
+
import type { MessageAttributes } from '../models/MessageModel';
|
|
3
|
+
import type { SummaryLanguage } from '../../shared/i18n/locales';
|
|
4
|
+
import { LANGUAGE_NAMES } from '../../shared/i18n/locales';
|
|
5
|
+
|
|
6
|
+
function getLanguageInstruction(language: SummaryLanguage): string {
|
|
7
|
+
if (language === 'original') {
|
|
8
|
+
return 'Respond in the same language as the page content.';
|
|
9
|
+
}
|
|
10
|
+
return `Respond in ${LANGUAGE_NAMES[language]}.`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getTldrSystemPrompt(language: SummaryLanguage): string {
|
|
14
|
+
return `You are a concise summarizer. Given the text content of a web page, produce a clear and informative TLDR summary.
|
|
15
|
+
|
|
16
|
+
If the page is a single article or post: focus on the key points, main arguments, and important details. Aim for 3-6 sentences.
|
|
17
|
+
|
|
18
|
+
If the page is a list of posts, a feed, or an index page: provide a brief overview of what the page contains, mention the main topics and notable items. Don't summarize only the first post — cover the whole page.
|
|
19
|
+
|
|
20
|
+
Use plain language. ${getLanguageInstruction(language)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getChatSystemPrompt(language: SummaryLanguage): string {
|
|
24
|
+
const langInstruction = language === 'original'
|
|
25
|
+
? 'Respond in the same language as the TLDR summary above.'
|
|
26
|
+
: `Respond in ${LANGUAGE_NAMES[language]}.`;
|
|
27
|
+
return `You are a helpful assistant discussing a web page the user just read. You have access to the page content and its TLDR summary. Answer follow-up questions about the content clearly and concisely. ${langInstruction}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ChatMessage {
|
|
31
|
+
role: string;
|
|
32
|
+
content: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class OpenAIService {
|
|
36
|
+
private settings: SettingsModel;
|
|
37
|
+
|
|
38
|
+
constructor(settingsModel: SettingsModel) {
|
|
39
|
+
this.settings = settingsModel;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async generateTldrStream(
|
|
43
|
+
pageContent: string,
|
|
44
|
+
pageTitle: string,
|
|
45
|
+
language: SummaryLanguage,
|
|
46
|
+
onChunk: (text: string) => void
|
|
47
|
+
): Promise<string> {
|
|
48
|
+
const messages: ChatMessage[] = [
|
|
49
|
+
{ role: 'system', content: getTldrSystemPrompt(language) },
|
|
50
|
+
{
|
|
51
|
+
role: 'user',
|
|
52
|
+
content: `Page title: "${pageTitle}"\n\nPage content:\n${pageContent.slice(0, 12000)}`,
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
return this._chatStream(messages, onChunk);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async askFollowUpStream(
|
|
60
|
+
chatMessages: MessageAttributes[],
|
|
61
|
+
pageContent: string,
|
|
62
|
+
pageTldr: string,
|
|
63
|
+
language: SummaryLanguage,
|
|
64
|
+
onChunk: (text: string) => void
|
|
65
|
+
): Promise<string> {
|
|
66
|
+
const systemMessage: ChatMessage = {
|
|
67
|
+
role: 'system',
|
|
68
|
+
content: `${getChatSystemPrompt(language)}\n\nPage TLDR:\n${pageTldr}\n\nFull page content (truncated):\n${pageContent.slice(0, 8000)}`,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return this._chatStream([systemMessage, ...chatMessages], onChunk);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private async _chatStream(
|
|
75
|
+
messages: ChatMessage[],
|
|
76
|
+
onChunk: (text: string) => void
|
|
77
|
+
): Promise<string> {
|
|
78
|
+
const apiKey = this.settings.get('apiKey');
|
|
79
|
+
if (!apiKey) throw new Error('API key not configured');
|
|
80
|
+
|
|
81
|
+
const model = this.settings.get('model');
|
|
82
|
+
|
|
83
|
+
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: {
|
|
86
|
+
'Content-Type': 'application/json',
|
|
87
|
+
Authorization: `Bearer ${apiKey}`,
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify({ model, messages, stream: true }),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
const err = await response.json().catch(() => ({}));
|
|
94
|
+
throw new Error(err.error?.message || `OpenAI API error: ${response.status}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const reader = response.body!.getReader();
|
|
98
|
+
const decoder = new TextDecoder();
|
|
99
|
+
let fullText = '';
|
|
100
|
+
let buffer = '';
|
|
101
|
+
|
|
102
|
+
while (true) {
|
|
103
|
+
const { done, value } = await reader.read();
|
|
104
|
+
if (done) break;
|
|
105
|
+
|
|
106
|
+
buffer += decoder.decode(value, { stream: true });
|
|
107
|
+
const lines = buffer.split('\n');
|
|
108
|
+
buffer = lines.pop() || '';
|
|
109
|
+
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
const trimmed = line.trim();
|
|
112
|
+
if (!trimmed || !trimmed.startsWith('data: ')) continue;
|
|
113
|
+
|
|
114
|
+
const data = trimmed.slice(6);
|
|
115
|
+
if (data === '[DONE]') break;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(data);
|
|
119
|
+
const delta = parsed.choices?.[0]?.delta?.content;
|
|
120
|
+
if (delta) {
|
|
121
|
+
fullText += delta;
|
|
122
|
+
onChunk(fullText);
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// skip malformed chunks
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return fullText;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export default OpenAIService;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
.chat {
|
|
3
|
+
@apply bg-white rounded-2xl border border-surface-200 shadow-sm overflow-hidden;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.chat__header {
|
|
7
|
+
@apply px-6 py-4 border-b border-surface-100;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.chat__title {
|
|
11
|
+
@apply text-sm font-semibold text-surface-700 uppercase tracking-wider;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.chat__messages {
|
|
15
|
+
@apply px-6 py-4 flex flex-col gap-4 max-h-96 overflow-y-auto;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.chat__empty {
|
|
19
|
+
@apply text-sm text-surface-400 text-center py-6;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.chat__input-wrap {
|
|
23
|
+
@apply px-4 py-3 border-t border-surface-100 flex gap-2;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.chat__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 resize-none;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.chat__send-btn {
|
|
32
|
+
@apply btn btn--primary px-4;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.chat__send-btn:disabled {
|
|
36
|
+
@apply bg-surface-300 text-surface-500 cursor-not-allowed shadow-none;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
@import 'tailwindcss/base';
|
|
2
|
+
@import 'tailwindcss/components';
|
|
3
|
+
|
|
4
|
+
@import './layout.css';
|
|
5
|
+
@import './settings.css';
|
|
6
|
+
@import './tldr.css';
|
|
7
|
+
@import './chat.css';
|
|
8
|
+
@import './message.css';
|
|
9
|
+
@import './loader.css';
|
|
10
|
+
@import './scrollbar.css';
|
|
11
|
+
@import './language-selector.css';
|
|
12
|
+
|
|
13
|
+
@import 'tailwindcss/utilities';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
@layer components {
|
|
2
|
+
.lang-selector {
|
|
3
|
+
@apply flex items-center gap-3;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.lang-selector__field {
|
|
7
|
+
@apply flex flex-col gap-1;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.lang-selector__label {
|
|
11
|
+
@apply text-[10px] font-medium text-surface-400 uppercase tracking-wider;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.lang-selector__select {
|
|
15
|
+
@apply px-2 py-1 text-xs border border-surface-300 rounded-lg bg-white text-surface-700
|
|
16
|
+
focus:outline-none focus:ring-1 focus:ring-primary-200 focus:border-primary-400 cursor-pointer;
|
|
17
|
+
}
|
|
18
|
+
}
|