diff --git a/.github/workflows/extension-release.yml b/.github/workflows/extension-release.yml new file mode 100644 index 00000000..3babac83 --- /dev/null +++ b/.github/workflows/extension-release.yml @@ -0,0 +1,118 @@ +name: huntly extension release workflow + +on: + push: + tags: [ 'ext/v*.*.*' ] + +permissions: + contents: read + +jobs: + create-release: + permissions: + contents: write + runs-on: ubuntu-latest + outputs: + release_id: ${{ steps.create-release.outputs.id }} + release_upload_url: ${{ steps.create-release.outputs.upload_url }} + version: ${{ steps.get_version.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Get version from tag + id: get_version + run: | + # Extract version from tag (ext/v1.0.0 -> 1.0.0) + TAG_NAME=${GITHUB_REF#refs/tags/} + VERSION=${TAG_NAME#ext/v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT + + - name: Get tag message + id: tag + run: | + git fetch --depth=1 origin +refs/tags/*:refs/tags/* + TAG_NAME=${GITHUB_REF#refs/tags/} + echo "message<> $GITHUB_OUTPUT + echo "$(git tag -l --format='%(contents)' $TAG_NAME)" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create Release + id: create-release + uses: ncipollo/release-action@v1 + with: + draft: true + name: Extension ${{ steps.get_version.outputs.version }} + tag: ${{ steps.get_version.outputs.tag_name }} + body: "${{ steps.tag.outputs.message }}" + + build-upload: + runs-on: ubuntu-latest + needs: create-release + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set Node.js + uses: actions/setup-node@v3 + with: + node-version: "20.10.0" + cache: "yarn" + cache-dependency-path: "app/extension/yarn.lock" + + - name: Setup yarn + run: npm install -g yarn --version 1.22.19 + + - name: Install extension dependencies + run: | + cd app/extension + yarn install + + - name: Create extension bundle (Chrome) + run: | + cd app/extension + yarn build + env: + CI: false + EXTENSION_VERSION: ${{ needs.create-release.outputs.version }} + + - name: Create extension bundle (Firefox) + run: | + cd app/extension + yarn build:firefox + env: + CI: false + EXTENSION_VERSION: ${{ needs.create-release.outputs.version }} + + - name: Package release files + run: | + mkdir -p release + cd app/extension + zip -r ../../release/huntly-chrome-extension-${{ needs.create-release.outputs.version }}.zip ./dist/* + zip -r ../../release/huntly-firefox-extension-${{ needs.create-release.outputs.version }}.zip ./dist-firefox/* + + - name: Upload Chrome extension to release + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.release_upload_url }} + asset_path: release/huntly-chrome-extension-${{ needs.create-release.outputs.version }}.zip + asset_name: huntly-chrome-extension-${{ needs.create-release.outputs.version }}.zip + asset_content_type: application/zip + + - name: Upload Firefox extension to release + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.release_upload_url }} + asset_path: release/huntly-firefox-extension-${{ needs.create-release.outputs.version }}.zip + asset_name: huntly-firefox-extension-${{ needs.create-release.outputs.version }}.zip + asset_content_type: application/zip + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7289a04f..983ae079 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: huntly release workflow on: push: - tags: [ v\d+\.\d+\.\d+ ] + tags: [ 'v*.*.*' ] permissions: contents: read @@ -78,19 +78,6 @@ jobs: CI: false REACT_APP_VERSION: ${{ steps.get_version.outputs.version }} - - name: Install extension dependencies - #if: steps.yarn-cache.outputs.cache-hit != 'true' - run: | - cd app/extension - yarn install - - - name: Create extension bundle - run: | - cd app/extension - yarn build - env: - CI: false - - name: Set up JDK 11 uses: actions/setup-java@v3 with: @@ -150,14 +137,12 @@ jobs: - name: package release file run: | - mkdir release release/huntly-client release/huntly-extension + mkdir release release/huntly-client mv app/server/huntly-server/target/huntly-*.jar release/huntly-server.jar mv app/client/build release/huntly-client - mv app/extension/dist/ release/huntly-extension cd release zip -r huntly-client-${{ steps.get_version.outputs.version-without-v }}.zip ./huntly-client/* - zip -r huntly-browser-extension-${{ steps.get_version.outputs.version-without-v }}.zip ./huntly-extension/* - + - name: Upload client-build to release id: upload-client-asset uses: actions/upload-release-asset@v1 @@ -169,17 +154,6 @@ jobs: asset_name: huntly-client-${{ steps.get_version.outputs.version-without-v }}.zip asset_content_type: application/zip - - name: Upload extension-build to release - id: upload-extension-asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create-release.outputs.release_upload_url }} - asset_path: release/huntly-browser-extension-${{ steps.get_version.outputs.version-without-v }}.zip - asset_name: huntly-browser-extension-${{ steps.get_version.outputs.version-without-v }}.zip - asset_content_type: application/zip - - name: Upload server-build to release id: upload-server-asset uses: actions/upload-release-asset@v1 diff --git a/README.md b/README.md index bfb4d021..61db3cb8 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Self-hosted information hub with a powerful browser extension that captures, pro |---------|-------------| | 🤖 **AI Content Processing** | Leverage AI for summarization, translation, and intelligent content analysis with custom shortcuts | | 🔌 **MCP Server Integration** | Built-in Model Context Protocol (MCP) server enabling AI assistants (Claude, Cursor, etc.) to access your knowledge base, search content, and retrieve RSS feeds, tweets, GitHub stars, and highlights | -| 📚 **Web Archiving** | Automatically save and archive web pages with content extraction using Mozilla Readability | +| 📚 **Web Archiving** | Automatically save and archive web pages with content extraction using Defuddle and Mozilla Readability | | 📡 **RSS Feed Management** | Centralize all your RSS feeds with intelligent categorization, OPML import/export, and full-text search | | 🔍 **Powerful Full-Text Search** | Apache Lucene with IK Analyzer for Chinese text tokenization, boolean operators, and fuzzy search | | 🐦 **Social Media Integration** | Special handling for Twitter/X with automatic tweet thread reconstruction and media preservation | @@ -54,7 +54,7 @@ Self-hosted information hub with a powerful browser extension that captures, pro - [x] Export all saved content to Markdown - [x] Flexible Organization: Collections -- [ ] Enhanced extension with standalone AI processing (no server required) +- [x] Enhanced extension with standalone AI processing (no server required) ## Screenshot diff --git a/README.zh.md b/README.zh.md index 71885c1f..ef4b4884 100644 --- a/README.zh.md +++ b/README.zh.md @@ -42,7 +42,7 @@ |---------|-------------| | 🤖 **AI 内容处理** | 利用 AI 进行摘要、翻译和智能内容分析,支持自定义快捷指令 | | 🔌 **MCP 服务器集成** | 内置 Model Context Protocol (MCP) 服务器,让 AI 助手(Claude、Cursor 等)可以访问您的知识库、搜索内容、获取 RSS 订阅、推文、GitHub stars 和高亮标注 | -| 📚 **网页归档** | 使用 Mozilla Readability 自动保存和归档网页,提取正文内容 | +| 📚 **网页归档** | 使用 Defuddle 和 Mozilla Readability 自动保存和归档网页,提取正文内容 | | 📡 **RSS 订阅管理** | 集中管理所有 RSS 订阅,支持智能分类、OPML 导入/导出和全文搜索 | | 🔍 **强大的全文搜索** | Apache Lucene 搜索引擎,IK 分词器支持中文分词,布尔运算符和模糊搜索 | | 🐦 **社交媒体集成** | 特殊处理 Twitter/X 内容,自动重建推文线程并保存媒体 | @@ -54,7 +54,7 @@ - [x] 将所有保存的内容导出为 Markdown - [x] 灵活的组织方式:收藏夹 -- [ ] 增强扩展功能,支持独立 AI 处理(无需服务器) +- [x] 增强扩展功能,支持独立 AI 处理(无需服务器) ## 系统截图 diff --git a/app/client/src/App.tsx b/app/client/src/App.tsx index 4820284b..a7552ed0 100644 --- a/app/client/src/App.tsx +++ b/app/client/src/App.tsx @@ -30,6 +30,7 @@ import SettingsFeeds from "./pages/settings/SettingsFeeds"; import SettingsLibrary from "./pages/settings/SettingsLibrary"; import SettingsAccount from "./pages/settings/SettingsAccount"; import SettingsGithub from "./pages/settings/SettingsGithub"; +import SettingsX from "./pages/settings/SettingsX"; function App() { const router = createBrowserRouter( @@ -56,6 +57,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/app/client/src/api/api.ts b/app/client/src/api/api.ts index e38fc491..e86be473 100644 --- a/app/client/src/api/api.ts +++ b/app/client/src/api/api.ts @@ -21,6 +21,56 @@ import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObj // @ts-ignore import { BASE_PATH, COLLECTION_FORMATS, RequestArgs, BaseAPI, RequiredError } from './base'; +/** + * + * @export + * @interface ApiResultOfBatchFilterResult + */ +export interface ApiResultOfBatchFilterResult { + /** + * + * @type {number} + * @memberof ApiResultOfBatchFilterResult + */ + 'code'?: number; + /** + * + * @type {BatchFilterResult} + * @memberof ApiResultOfBatchFilterResult + */ + 'data'?: BatchFilterResult; + /** + * + * @type {string} + * @memberof ApiResultOfBatchFilterResult + */ + 'message'?: string; +} +/** + * + * @export + * @interface ApiResultOfBatchMoveResult + */ +export interface ApiResultOfBatchMoveResult { + /** + * + * @type {number} + * @memberof ApiResultOfBatchMoveResult + */ + 'code'?: number; + /** + * + * @type {BatchMoveResult} + * @memberof ApiResultOfBatchMoveResult + */ + 'data'?: BatchMoveResult; + /** + * + * @type {string} + * @memberof ApiResultOfBatchMoveResult + */ + 'message'?: string; +} /** * * @export @@ -320,6 +370,227 @@ export interface ArticleShortcut { */ 'updatedAt'?: string; } +/** + * + * @export + * @interface BatchFilterQuery + */ +export interface BatchFilterQuery { + /** + * + * @type {string} + * @memberof BatchFilterQuery + */ + 'author'?: string; + /** + * + * @type {number} + * @memberof BatchFilterQuery + */ + 'collectionId'?: number; + /** + * + * @type {string} + * @memberof BatchFilterQuery + */ + 'contentType'?: string; + /** + * + * @type {string} + * @memberof BatchFilterQuery + */ + 'endDate'?: string; + /** + * + * @type {boolean} + * @memberof BatchFilterQuery + */ + 'filterUnsorted'?: boolean; + /** + * + * @type {number} + * @memberof BatchFilterQuery + */ + 'page'?: number; + /** + * + * @type {boolean} + * @memberof BatchFilterQuery + */ + 'readLater'?: boolean; + /** + * + * @type {string} + * @memberof BatchFilterQuery + */ + 'saveStatus'?: string; + /** + * + * @type {number} + * @memberof BatchFilterQuery + */ + 'size'?: number; + /** + * + * @type {boolean} + * @memberof BatchFilterQuery + */ + 'starred'?: boolean; + /** + * + * @type {string} + * @memberof BatchFilterQuery + */ + 'startDate'?: string; +} +/** + * + * @export + * @interface BatchFilterResult + */ +export interface BatchFilterResult { + /** + * + * @type {number} + * @memberof BatchFilterResult + */ + 'currentPage'?: number; + /** + * + * @type {Array} + * @memberof BatchFilterResult + */ + 'items'?: Array; + /** + * + * @type {number} + * @memberof BatchFilterResult + */ + 'totalCount'?: number; + /** + * + * @type {number} + * @memberof BatchFilterResult + */ + 'totalPages'?: number; +} +/** + * + * @export + * @interface BatchMoveRequest + */ +export interface BatchMoveRequest { + /** + * + * @type {string} + * @memberof BatchMoveRequest + */ + 'collectedAtMode'?: string; + /** + * + * @type {BatchFilterQuery} + * @memberof BatchMoveRequest + */ + 'filterQuery'?: BatchFilterQuery; + /** + * + * @type {Array} + * @memberof BatchMoveRequest + */ + 'pageIds'?: Array; + /** + * + * @type {boolean} + * @memberof BatchMoveRequest + */ + 'selectAll'?: boolean; + /** + * + * @type {number} + * @memberof BatchMoveRequest + */ + 'targetCollectionId'?: number; +} +/** + * + * @export + * @interface BatchMoveResult + */ +export interface BatchMoveResult { + /** + * + * @type {number} + * @memberof BatchMoveResult + */ + 'successCount'?: number; + /** + * + * @type {number} + * @memberof BatchMoveResult + */ + 'totalAffected'?: number; +} +/** + * + * @export + * @interface BatchPageItem + */ +export interface BatchPageItem { + /** + * + * @type {string} + * @memberof BatchPageItem + */ + 'author'?: string; + /** + * + * @type {string} + * @memberof BatchPageItem + */ + 'collectedAt'?: string; + /** + * + * @type {number} + * @memberof BatchPageItem + */ + 'contentType'?: number; + /** + * + * @type {string} + * @memberof BatchPageItem + */ + 'description'?: string; + /** + * + * @type {number} + * @memberof BatchPageItem + */ + 'id'?: number; + /** + * + * @type {string} + * @memberof BatchPageItem + */ + 'pageJsonProperties'?: string; + /** + * + * @type {string} + * @memberof BatchPageItem + */ + 'publishTime'?: string; + /** + * + * @type {string} + * @memberof BatchPageItem + */ + 'title'?: string; + /** + * + * @type {string} + * @memberof BatchPageItem + */ + 'url'?: string; +} /** * * @export @@ -746,6 +1017,18 @@ export interface Connector { * @memberof Connector */ 'folderId'?: number; + /** + * + * @type {string} + * @memberof Connector + */ + 'httpEtag'?: string; + /** + * + * @type {string} + * @memberof Connector + */ + 'httpLastModified'?: string; /** * * @type {string} @@ -1060,6 +1343,12 @@ export interface GlobalSetting { * @memberof GlobalSetting */ 'autoSaveSiteBlacklists'?: string; + /** + * + * @type {number} + * @memberof GlobalSetting + */ + 'autoSaveTweetMinLikes'?: number; /** * * @type {boolean} @@ -1224,6 +1513,12 @@ export interface InterceptTweets { * @memberof InterceptTweets */ 'loginScreenName'?: string; + /** + * + * @type {number} + * @memberof InterceptTweets + */ + 'minLikes'?: number; } /** * @@ -4131,6 +4426,178 @@ export class BasicErrorControllerApi extends BaseAPI { } +/** + * BatchOrganizeControllerApi - axios parameter creator + * @export + */ +export const BatchOrganizeControllerApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @summary batchMoveToCollection + * @param {BatchMoveRequest} [batchMoveRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + batchMoveToCollectionUsingPOST: async (batchMoveRequest?: BatchMoveRequest, options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/api/page/batch/moveToCollection`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(batchMoveRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary filterPages + * @param {BatchFilterQuery} [batchFilterQuery] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + filterPagesUsingPOST: async (batchFilterQuery?: BatchFilterQuery, options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/api/page/batch/filter`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(batchFilterQuery, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * BatchOrganizeControllerApi - functional programming interface + * @export + */ +export const BatchOrganizeControllerApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = BatchOrganizeControllerApiAxiosParamCreator(configuration) + return { + /** + * + * @summary batchMoveToCollection + * @param {BatchMoveRequest} [batchMoveRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async batchMoveToCollectionUsingPOST(batchMoveRequest?: BatchMoveRequest, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.batchMoveToCollectionUsingPOST(batchMoveRequest, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @summary filterPages + * @param {BatchFilterQuery} [batchFilterQuery] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async filterPagesUsingPOST(batchFilterQuery?: BatchFilterQuery, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.filterPagesUsingPOST(batchFilterQuery, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * BatchOrganizeControllerApi - factory interface + * @export + */ +export const BatchOrganizeControllerApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = BatchOrganizeControllerApiFp(configuration) + return { + /** + * + * @summary batchMoveToCollection + * @param {BatchMoveRequest} [batchMoveRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + batchMoveToCollectionUsingPOST(batchMoveRequest?: BatchMoveRequest, options?: any): AxiosPromise { + return localVarFp.batchMoveToCollectionUsingPOST(batchMoveRequest, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary filterPages + * @param {BatchFilterQuery} [batchFilterQuery] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + filterPagesUsingPOST(batchFilterQuery?: BatchFilterQuery, options?: any): AxiosPromise { + return localVarFp.filterPagesUsingPOST(batchFilterQuery, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * BatchOrganizeControllerApi - object-oriented interface + * @export + * @class BatchOrganizeControllerApi + * @extends {BaseAPI} + */ +export class BatchOrganizeControllerApi extends BaseAPI { + /** + * + * @summary batchMoveToCollection + * @param {BatchMoveRequest} [batchMoveRequest] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof BatchOrganizeControllerApi + */ + public batchMoveToCollectionUsingPOST(batchMoveRequest?: BatchMoveRequest, options?: AxiosRequestConfig) { + return BatchOrganizeControllerApiFp(this.configuration).batchMoveToCollectionUsingPOST(batchMoveRequest, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary filterPages + * @param {BatchFilterQuery} [batchFilterQuery] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof BatchOrganizeControllerApi + */ + public filterPagesUsingPOST(batchFilterQuery?: BatchFilterQuery, options?: AxiosRequestConfig) { + return BatchOrganizeControllerApiFp(this.configuration).filterPagesUsingPOST(batchFilterQuery, options).then((request) => request(this.axios, this.basePath)); + } +} + + /** * CollectionControllerApi - axios parameter creator * @export diff --git a/app/client/src/components/Navigation/Settings/SettingsNav.tsx b/app/client/src/components/Navigation/Settings/SettingsNav.tsx index 99c182b9..39f3b199 100644 --- a/app/client/src/components/Navigation/Settings/SettingsNav.tsx +++ b/app/client/src/components/Navigation/Settings/SettingsNav.tsx @@ -1,14 +1,23 @@ import { Box } from "@mui/material"; +import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon"; import SettingsApplicationsIcon from '@mui/icons-material/SettingsApplications'; import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; import RssFeedIcon from '@mui/icons-material/RssFeed'; import AccountBoxIcon from '@mui/icons-material/AccountBox'; import LibraryBooksIcon from '@mui/icons-material/LibraryBooks'; import GitHubIcon from '@mui/icons-material/GitHub'; -import { SvgIconProps } from "@mui/material/SvgIcon"; import NavListItem from "../shared/NavListItem"; import SidebarHeader from "../shared/SidebarHeader"; +// X (Twitter) Icon Component - same as in PrimaryNavigation +function XIcon(props: SvgIconProps) { + return ( + + + + ); +} + interface SettingsNavItem { labelText: string; labelIcon: React.ElementType; @@ -21,6 +30,7 @@ const settingsItems: SettingsNavItem[] = [ { labelText: 'Huntly AI', labelIcon: AutoAwesomeIcon, linkTo: '/settings/huntly-ai', useGradient: true }, { labelText: 'Library', labelIcon: LibraryBooksIcon, linkTo: '/settings/library' }, { labelText: 'Feeds', labelIcon: RssFeedIcon, linkTo: '/settings/feeds' }, + { labelText: 'X', labelIcon: XIcon, linkTo: '/settings/x' }, { labelText: 'GitHub', labelIcon: GitHubIcon, linkTo: '/settings/github' }, { labelText: 'Account', labelIcon: AccountBoxIcon, linkTo: '/settings/account' }, ]; diff --git a/app/client/src/components/SettingModal/LibrarySetting.tsx b/app/client/src/components/SettingModal/LibrarySetting.tsx index be841c26..c2073e3d 100644 --- a/app/client/src/components/SettingModal/LibrarySetting.tsx +++ b/app/client/src/components/SettingModal/LibrarySetting.tsx @@ -10,10 +10,8 @@ import { LibraryExportStatus, startLibraryExport } from "../../api/libraryExport"; -import { TwitterSaveRulesSetting } from "./TwitterSaveRulesSetting"; import BatchOrganizeSetting from "./BatchOrganizeSetting"; import DownloadIcon from '@mui/icons-material/Download'; -import RuleIcon from '@mui/icons-material/Rule'; import DriveFileMoveIcon from '@mui/icons-material/DriveFileMove'; const POLL_INTERVAL_MS = 5000; @@ -242,18 +240,14 @@ export default function LibrarySetting() {
- } iconPosition="start" label="Save rules" {...a11yProps(0)} sx={{ minHeight: 48 }} /> - } iconPosition="start" label="Batch Organize" {...a11yProps(1)} sx={{ minHeight: 48 }} /> - } iconPosition="start" label="Export" {...a11yProps(2)} sx={{ minHeight: 48 }} /> + } iconPosition="start" label="Batch Organize" {...a11yProps(0)} sx={{ minHeight: 48 }} /> + } iconPosition="start" label="Export" {...a11yProps(1)} sx={{ minHeight: 48 }} /> - - - - +
diff --git a/app/client/src/components/SettingModal/XSetting.tsx b/app/client/src/components/SettingModal/XSetting.tsx new file mode 100644 index 00000000..39c13d8a --- /dev/null +++ b/app/client/src/components/SettingModal/XSetting.tsx @@ -0,0 +1,115 @@ +import { + TextField, + Typography, + Box, + alpha, + Alert, +} from "@mui/material"; +import React, { useState, useRef } from "react"; +import { SettingControllerApiFactory } from "../../api"; +import { useSnackbar } from "notistack"; +import { useQuery } from "@tanstack/react-query"; +import FilterAltIcon from "@mui/icons-material/FilterAlt"; +import SettingSectionTitle from "./SettingSectionTitle"; +import { TwitterSaveRulesSetting } from "./TwitterSaveRulesSetting"; + +export const XSetting: React.FC = () => { + const { enqueueSnackbar } = useSnackbar(); + const api = SettingControllerApiFactory(); + const [minLikes, setMinLikes] = useState(0); + const [minLikesLoading, setMinLikesLoading] = useState(true); + const originalMinLikesRef = useRef(0); + + // Fetch global setting for min likes + const { data: globalSetting, refetch: refetchGlobalSetting } = useQuery( + ["global_setting_for_x"], + async () => { + const result = await api.getGlobalSettingUsingGET(); + return result.data; + }, + { + onSuccess: (data) => { + const value = data?.autoSaveTweetMinLikes ?? 0; + setMinLikes(value); + originalMinLikesRef.current = value; + setMinLikesLoading(false); + }, + onError: () => { + setMinLikesLoading(false); + } + } + ); + + const handleMinLikesSave = async () => { + // Only save if value has changed + if (minLikes === originalMinLikesRef.current) { + return; + } + if (!globalSetting) return; + try { + await api.saveGlobalSettingUsingPOST({ + ...globalSetting, + autoSaveTweetMinLikes: minLikes, + }); + originalMinLikesRef.current = minLikes; + enqueueSnackbar('Minimum likes setting saved.', { + variant: 'success', + anchorOrigin: { vertical: 'bottom', horizontal: 'center' } + }); + refetchGlobalSetting(); + } catch (err) { + enqueueSnackbar('Failed to save minimum likes setting.', { + variant: 'error', + anchorOrigin: { vertical: 'bottom', horizontal: 'center' } + }); + } + }; + + return ( +
+ + Global Filter + + + + + + setMinLikes(Math.max(0, parseInt(e.target.value) || 0))} + onBlur={handleMinLikesSave} + inputProps={{ min: 0, max: 100000 }} + disabled={minLikesLoading} + sx={{ width: { xs: '100%', sm: 180 } }} + /> + + + Set to 0 to save all tweets. This global filter doesn't affect user rules. + + + + + + + + + +
+ ); +}; + +export default XSetting; + diff --git a/app/client/src/pages/settings/SettingsX.tsx b/app/client/src/pages/settings/SettingsX.tsx new file mode 100644 index 00000000..f2b41fe9 --- /dev/null +++ b/app/client/src/pages/settings/SettingsX.tsx @@ -0,0 +1,33 @@ +import MainContainer from "../../components/MainContainer"; +import XSetting from "../../components/SettingModal/XSetting"; +import SubHeader from "../../components/SubHeader"; +import SvgIcon, { SvgIconProps } from "@mui/material/SvgIcon"; +import "../../styles/settings.css"; + +// X (Twitter) Icon Component - same as in PrimaryNavigation +function XIcon(props: SvgIconProps) { + return ( + + + + ); +} + +const SettingsX = () => { + return ( + + +
+
+ +
+
+
+ ); +}; + +export default SettingsX; + diff --git a/app/extension/package.json b/app/extension/package.json index 6fc5c843..e0b95d6a 100644 --- a/app/extension/package.json +++ b/app/extension/package.json @@ -20,18 +20,30 @@ "url": "https://github.com/lcomplete/huntly" }, "dependencies": { + "@ai-sdk/anthropic": "^1.2.12", + "@ai-sdk/azure": "^1.3.24", + "@ai-sdk/deepseek": "^0.1.8", + "@ai-sdk/google": "^1.2.20", + "@ai-sdk/groq": "^1.1.14", + "@ai-sdk/openai": "^1.3.22", "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", "@mozilla/readability": "^0.4.2", "@mui/icons-material": "^5.11.11", "@mui/material": "^5.11.14", + "@types/turndown": "^5.0.6", + "ai": "^4.3.16", + "defuddle": "^0.6.6", "formik": "^2.2.9", + "ollama-ai-provider": "^1.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", "react-modal": "^3.16.1", "remark-gfm": "^4.0.1", - "yup": "^1.0.2" + "turndown": "^7.2.2", + "yup": "^1.0.2", + "zod": "^3.23.8" }, "devDependencies": { "@types/chrome": "0.0.158", diff --git a/app/extension/public/icon.png b/app/extension/public/icon.png deleted file mode 100644 index 60b97abe..00000000 Binary files a/app/extension/public/icon.png and /dev/null differ diff --git a/app/extension/public/manifest.json b/app/extension/public/manifest.json index 9fcef070..21ef43bb 100644 --- a/app/extension/public/manifest.json +++ b/app/extension/public/manifest.json @@ -2,9 +2,10 @@ "manifest_version": 3, "name": "Huntly", "description": "Huntly - Automatic saving browsed contents", - "version": "0.5.0", + "version": "0.5.1", "options_ui": { - "page": "options.html" + "page": "options.html", + "open_in_tab": true }, "icons": { "16": "favicon-16x16.png", diff --git a/app/extension/src/ai/providers.ts b/app/extension/src/ai/providers.ts new file mode 100644 index 00000000..8205e5fe --- /dev/null +++ b/app/extension/src/ai/providers.ts @@ -0,0 +1,222 @@ +import { createOpenAI } from '@ai-sdk/openai'; +import { createAnthropic } from '@ai-sdk/anthropic'; +import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import { createDeepSeek } from '@ai-sdk/deepseek'; +import { createGroq } from '@ai-sdk/groq'; +import { createAzure } from '@ai-sdk/azure'; +import { ollama, createOllama } from 'ollama-ai-provider'; +import { generateText, LanguageModelV1 } from 'ai'; +import { + AIProviderConfig, + ConnectionTestResult, + ProviderType, + PROVIDER_REGISTRY, +} from './types'; + +export function createProviderModel( + config: AIProviderConfig, + modelId?: string +): LanguageModelV1 | null { + const model = modelId || config.enabledModels[0]; + if (!model) return null; + + const meta = PROVIDER_REGISTRY[config.type]; + + switch (config.type) { + case 'openai': { + const provider = createOpenAI({ + apiKey: config.apiKey, + baseURL: config.baseUrl || undefined, + }); + return provider(model) as LanguageModelV1; + } + + case 'anthropic': { + const provider = createAnthropic({ + apiKey: config.apiKey, + baseURL: config.baseUrl || undefined, + headers: { + 'anthropic-dangerous-direct-browser-access': 'true', + }, + }); + return provider(model) as LanguageModelV1; + } + + case 'google': { + const provider = createGoogleGenerativeAI({ + apiKey: config.apiKey, + baseURL: config.baseUrl || undefined, + }); + return provider(model) as LanguageModelV1; + } + + case 'deepseek': { + const provider = createDeepSeek({ + apiKey: config.apiKey, + baseURL: config.baseUrl || undefined, + }); + return provider(model) as LanguageModelV1; + } + + case 'groq': { + const provider = createGroq({ + apiKey: config.apiKey, + }); + return provider(model) as LanguageModelV1; + } + + case 'ollama': { + if (config.baseUrl) { + const provider = createOllama({ + baseURL: config.baseUrl, + }); + return provider(model) as LanguageModelV1; + } + return ollama(model) as LanguageModelV1; + } + + case 'azure-openai': + case 'azure-ai': { + if (!config.baseUrl) return null; + const provider = createAzure({ + apiKey: config.apiKey, + baseURL: config.baseUrl, + }); + return provider(model) as LanguageModelV1; + } + + case 'zhipu': { + // Zhipu AI uses OpenAI-compatible API + const provider = createOpenAI({ + apiKey: config.apiKey, + baseURL: config.baseUrl || 'https://open.bigmodel.cn/api/paas/v4', + }); + return provider(model) as LanguageModelV1; + } + + case 'minimax': { + // MiniMax uses OpenAI-compatible API + const provider = createOpenAI({ + apiKey: config.apiKey, + baseURL: config.baseUrl || 'https://api.minimax.chat/v1', + }); + return provider(model) as LanguageModelV1; + } + + case 'huntly-server': { + // Huntly Server is a special provider that uses the server's AI + // It doesn't use the AI SDK directly, but through SSE streaming + // Return null here as it's handled separately + return null; + } + + default: + return null; + } +} + +export async function testProviderConnection( + config: AIProviderConfig +): Promise { + try { + // Huntly Server doesn't use the AI SDK directly + if (config.type === 'huntly-server') { + return { + success: true, + message: 'Huntly Server provider uses server-side AI configuration', + }; + } + + // Check API key requirement (ollama doesn't require one) + if (config.type !== 'ollama' && !config.apiKey) { + return { + success: false, + message: 'API Key is required', + }; + } + + if ( + (config.type === 'azure-openai' || config.type === 'azure-ai') && + !config.baseUrl + ) { + return { + success: false, + message: 'API URL is required for Azure', + }; + } + + const meta = PROVIDER_REGISTRY[config.type]; + const testModel = + config.enabledModels[0] || meta.defaultModels[0]?.id; + + if (!testModel) { + return { + success: false, + message: 'No model available for testing', + }; + } + + const model = createProviderModel( + { ...config, enabledModels: [testModel] }, + testModel + ); + + if (!model) { + return { + success: false, + message: 'Failed to create provider instance', + }; + } + + const result = await generateText({ + model, + prompt: 'Say "OK" in one word.', + maxTokens: 5, + }); + + if (result.text) { + return { + success: true, + message: `Connected! Response: ${result.text.trim()}`, + }; + } + + return { + success: false, + message: 'No response received', + }; + } catch (error: any) { + let message = error.message || 'Connection failed'; + + if (message.includes('401') || message.includes('Unauthorized')) { + message = 'Invalid API Key'; + } else if (message.includes('403') || message.includes('Forbidden')) { + message = 'Access denied. Check your API Key permissions.'; + } else if (message.includes('404')) { + message = 'API endpoint not found. Check your API URL.'; + } else if (message.includes('CORS')) { + message = 'CORS error. The API may not support browser access.'; + } else if (message.includes('Failed to fetch') || message.includes('NetworkError')) { + message = 'Network error. Check your connection and API URL.'; + } + + return { + success: false, + message, + }; + } +} + +export async function fetchOllamaModels(baseUrl?: string): Promise { + try { + const url = (baseUrl || 'http://localhost:11434') + '/api/tags'; + const response = await fetch(url); + if (!response.ok) { + return []; + } + const data = await response.json(); + return (data.models || []).map((m: any) => m.name || m.model); + } catch { + return []; + } +} diff --git a/app/extension/src/ai/storage.ts b/app/extension/src/ai/storage.ts new file mode 100644 index 00000000..d660841f --- /dev/null +++ b/app/extension/src/ai/storage.ts @@ -0,0 +1,154 @@ +import { + AIProviderConfig, + AIProvidersStorage, + DEFAULT_AI_STORAGE, + PROVIDER_ORDER, + ProviderType, +} from './types'; +import { getApiBaseUrl } from '../services'; + +const AI_PROVIDERS_STORAGE_KEY = 'aiProviders'; + +export async function getAIProvidersStorage(): Promise { + const result = await chrome.storage.sync.get(AI_PROVIDERS_STORAGE_KEY); + const stored = result[AI_PROVIDERS_STORAGE_KEY]; + if (!stored) { + return DEFAULT_AI_STORAGE; + } + return { + ...DEFAULT_AI_STORAGE, + ...stored, + providers: { + ...DEFAULT_AI_STORAGE.providers, + ...(stored.providers || {}), + }, + }; +} + +export async function saveAIProvidersStorage( + storage: AIProvidersStorage +): Promise { + await chrome.storage.sync.set({ [AI_PROVIDERS_STORAGE_KEY]: storage }); +} + +export async function getProviderConfig( + type: ProviderType +): Promise { + const storage = await getAIProvidersStorage(); + return storage.providers[type] || null; +} + +export async function saveProviderConfig( + type: ProviderType, + config: AIProviderConfig +): Promise { + const storage = await getAIProvidersStorage(); + storage.providers[type] = { + ...config, + updatedAt: Date.now(), + }; + await saveAIProvidersStorage(storage); +} + +export async function deleteProviderConfig(type: ProviderType): Promise { + const storage = await getAIProvidersStorage(); + storage.providers[type] = null; + if (storage.defaultProvider === type) { + storage.defaultProvider = null; + } + await saveAIProvidersStorage(storage); +} + +export async function setDefaultProvider( + type: ProviderType | null +): Promise { + const storage = await getAIProvidersStorage(); + storage.defaultProvider = type; + await saveAIProvidersStorage(storage); +} + +export async function getDefaultProvider(): Promise { + const storage = await getAIProvidersStorage(); + + // If a default provider is explicitly set, use it + if (storage.defaultProvider) { + const config = storage.providers[storage.defaultProvider]; + if (config?.enabled) { + return config; + } + } + + // Otherwise, find the first enabled provider in PROVIDER_ORDER + for (const type of PROVIDER_ORDER) { + const config = storage.providers[type]; + if (config?.enabled) { + return config; + } + } + + return null; +} + +export async function getAllEnabledProviders(): Promise { + const storage = await getAIProvidersStorage(); + + // Return enabled providers in PROVIDER_ORDER + return PROVIDER_ORDER + .map((type) => storage.providers[type]) + .filter((p): p is AIProviderConfig => p !== null && p.enabled); +} + +/** + * Check if a provider is available (enabled and properly configured) + * For huntly-server, checks if server URL is configured + * For other providers, checks if they are enabled + */ +export async function isProviderAvailable(type: ProviderType): Promise { + if (type === 'huntly-server') { + const serverUrl = await getApiBaseUrl(); + return !!serverUrl; + } + + const storage = await getAIProvidersStorage(); + const config = storage.providers[type]; + return config?.enabled ?? false; +} + +/** + * Get all available provider types in order + * This considers huntly-server's special case (requires server URL) + */ +export async function getAvailableProviderTypes(): Promise { + const storage = await getAIProvidersStorage(); + const serverUrl = await getApiBaseUrl(); + + return PROVIDER_ORDER.filter((type) => { + if (type === 'huntly-server') { + return !!serverUrl; + } + const config = storage.providers[type]; + return config?.enabled ?? false; + }); +} + +/** + * Get the effective default provider type + * If a default is set and available, use it + * Otherwise, return the first available provider + * Returns null if no provider is available + */ +export async function getEffectiveDefaultProviderType(): Promise { + const storage = await getAIProvidersStorage(); + + // If a default provider is explicitly set, check if it's available + if (storage.defaultProvider) { + const isAvailable = await isProviderAvailable(storage.defaultProvider); + if (isAvailable) { + return storage.defaultProvider; + } + } + + // Otherwise, find the first available provider in PROVIDER_ORDER + const availableProviders = await getAvailableProviderTypes(); + return availableProviders.length > 0 ? availableProviders[0] : null; +} diff --git a/app/extension/src/ai/system-prompts.ts b/app/extension/src/ai/system-prompts.ts new file mode 100644 index 00000000..071161ec --- /dev/null +++ b/app/extension/src/ai/system-prompts.ts @@ -0,0 +1,902 @@ +import { LANGUAGES } from '../languages'; + +export type SystemPromptContent = { + name: string; + content: string; +} + +export const SYSTEM_PROMPT_TRANSLATIONS: Record> = { + system_summarize: { + en: { + name: 'Summarize', + content: `You are a professional article summarization assistant. Generate a high-quality summary following these requirements: + +1. Include main ideas and key information +2. Stay objective, no personal opinions +3. Clear structure, concise language +4. Keep it brief but comprehensive, no longer than half the original length + +You should respond in {lang}.`, + }, + zh: { + name: '总结', + content: `你是一位专业的文章摘要助手。请按照以下要求生成高质量摘要: + +1. 包含主要观点和关键信息 +2. 保持客观,不添加个人观点 +3. 结构清晰,语言简洁 +4. 简明扼要但内容全面,长度不超过原文一半 + +你需要使用{lang}进行回复。`, + }, + ja: { + name: '要約', + content: `あなたはプロフェッショナルな記事要約アシスタントです。以下の要件に従って高品質な要約を作成してください: + +1. 主要なアイデアと重要な情報を含める +2. 客観的に、個人的な意見は入れない +3. 明確な構造、簡潔な言葉遣い +4. 簡潔だが包括的に、元の長さの半分以下に + +{lang}で回答してください。`, + }, + ko: { + name: '요약', + content: `당신은 전문적인 기사 요약 어시스턴트입니다. 다음 요구사항에 따라 고품질 요약을 생성하세요: + +1. 주요 아이디어와 핵심 정보 포함 +2. 객관적으로 유지, 개인적인 의견 없음 +3. 명확한 구조, 간결한 언어 +4. 간결하지만 포괄적으로, 원문 길이의 절반 이하로 + +{lang}로 응답해 주세요.`, + }, + es: { + name: 'Resumir', + content: `Eres un asistente profesional de resumen de artículos. Genera un resumen de alta calidad siguiendo estos requisitos: + +1. Incluye las ideas principales y la información clave +2. Mantén la objetividad, sin opiniones personales +3. Estructura clara, lenguaje conciso +4. Breve pero completo, no más de la mitad de la longitud original + +Debes responder en {lang}.`, + }, + fr: { + name: 'Résumer', + content: `Vous êtes un assistant professionnel de résumé d'articles. Générez un résumé de haute qualité en suivant ces exigences : + +1. Inclure les idées principales et les informations clés +2. Rester objectif, sans opinions personnelles +3. Structure claire, langage concis +4. Bref mais complet, pas plus de la moitié de la longueur originale + +Veuillez répondre en {lang}.`, + }, + de: { + name: 'Zusammenfassen', + content: `Sie sind ein professioneller Artikelzusammenfassungsassistent. Erstellen Sie eine hochwertige Zusammenfassung gemäß diesen Anforderungen: + +1. Hauptideen und Schlüsselinformationen einbeziehen +2. Objektiv bleiben, keine persönlichen Meinungen +3. Klare Struktur, prägnante Sprache +4. Kurz aber umfassend, nicht länger als die Hälfte der Originallänge + +Bitte antworten Sie auf {lang}.`, + }, + ru: { + name: 'Резюме', + content: `Вы профессиональный помощник по составлению резюме статей. Создайте качественное резюме, следуя этим требованиям: + +1. Включите основные идеи и ключевую информацию +2. Сохраняйте объективность, без личных мнений +3. Четкая структура, лаконичный язык +4. Кратко, но всеобъемлюще, не более половины исходной длины + +Пожалуйста, отвечайте на {lang}.`, + }, + pt: { + name: 'Resumir', + content: `Você é um assistente profissional de resumo de artigos. Gere um resumo de alta qualidade seguindo estes requisitos: + +1. Inclua as ideias principais e informações-chave +2. Mantenha-se objetivo, sem opiniões pessoais +3. Estrutura clara, linguagem concisa +4. Breve mas abrangente, não mais que metade do comprimento original + +Por favor, responda em {lang}.`, + }, + it: { + name: 'Riassumere', + content: `Sei un assistente professionale per il riassunto di articoli. Genera un riassunto di alta qualità seguendo questi requisiti: + +1. Includi le idee principali e le informazioni chiave +2. Rimani obiettivo, nessuna opinione personale +3. Struttura chiara, linguaggio conciso +4. Breve ma completo, non più della metà della lunghezza originale + +Per favore rispondi in {lang}.`, + }, + ar: { + name: 'تلخيص', + content: `أنت مساعد محترف في تلخيص المقالات. قم بإنشاء ملخص عالي الجودة وفقاً للمتطلبات التالية: + +1. تضمين الأفكار الرئيسية والمعلومات الأساسية +2. البقاء موضوعياً، بدون آراء شخصية +3. هيكل واضح، لغة موجزة +4. موجز لكن شامل، لا يتجاوز نصف الطول الأصلي + +يرجى الرد باللغة {lang}.`, + }, + }, + system_translate: { + en: { + name: 'Translate', + content: `You are a professional translator. Translate the following article following these requirements: + +1. Preserve the original meaning and style +2. Use professional and idiomatic expressions +3. Accurately translate technical terms +4. Maintain the original paragraph structure + +You should respond in {lang}.`, + }, + zh: { + name: '翻译', + content: `你是一位专业翻译。请按照以下要求翻译文章: + +1. 保留原文的含义和风格 +2. 使用专业且地道的表达 +3. 准确翻译专业术语 +4. 保持原文的段落结构 + +你需要使用{lang}进行回复。`, + }, + ja: { + name: '翻訳', + content: `あなたはプロフェッショナルな翻訳者です。以下の要件に従って記事を翻訳してください: + +1. 原文の意味とスタイルを保持する +2. 専門的で自然な表現を使用する +3. 専門用語を正確に翻訳する +4. 元の段落構造を維持する + +{lang}で回答してください。`, + }, + ko: { + name: '번역', + content: `당신은 전문 번역가입니다. 다음 요구사항에 따라 기사를 번역하세요: + +1. 원문의 의미와 스타일 보존 +2. 전문적이고 관용적인 표현 사용 +3. 기술 용어를 정확하게 번역 +4. 원본 단락 구조 유지 + +{lang}로 응답해 주세요.`, + }, + es: { + name: 'Traducir', + content: `Eres un traductor profesional. Traduce el siguiente artículo siguiendo estos requisitos: + +1. Preserva el significado y estilo original +2. Usa expresiones profesionales e idiomáticas +3. Traduce con precisión los términos técnicos +4. Mantén la estructura de párrafos original + +Debes responder en {lang}.`, + }, + fr: { + name: 'Traduire', + content: `Vous êtes un traducteur professionnel. Traduisez l'article suivant en respectant ces exigences : + +1. Préserver le sens et le style d'origine +2. Utiliser des expressions professionnelles et idiomatiques +3. Traduire avec précision les termes techniques +4. Maintenir la structure des paragraphes d'origine + +Veuillez répondre en {lang}.`, + }, + de: { + name: 'Übersetzen', + content: `Sie sind ein professioneller Übersetzer. Übersetzen Sie den folgenden Artikel gemäß diesen Anforderungen: + +1. Die ursprüngliche Bedeutung und den Stil bewahren +2. Professionelle und idiomatische Ausdrücke verwenden +3. Fachbegriffe genau übersetzen +4. Die ursprüngliche Absatzstruktur beibehalten + +Bitte antworten Sie auf {lang}.`, + }, + ru: { + name: 'Перевод', + content: `Вы профессиональный переводчик. Переведите следующую статью, следуя этим требованиям: + +1. Сохраните оригинальный смысл и стиль +2. Используйте профессиональные и идиоматические выражения +3. Точно переводите технические термины +4. Сохраните оригинальную структуру абзацев + +Пожалуйста, отвечайте на {lang}.`, + }, + pt: { + name: 'Traduzir', + content: `Você é um tradutor profissional. Traduza o seguinte artigo seguindo estes requisitos: + +1. Preserve o significado e estilo original +2. Use expressões profissionais e idiomáticas +3. Traduza com precisão os termos técnicos +4. Mantenha a estrutura original dos parágrafos + +Por favor, responda em {lang}.`, + }, + it: { + name: 'Tradurre', + content: `Sei un traduttore professionista. Traduci il seguente articolo seguendo questi requisiti: + +1. Preserva il significato e lo stile originale +2. Usa espressioni professionali e idiomatiche +3. Traduci con precisione i termini tecnici +4. Mantieni la struttura dei paragrafi originale + +Per favore rispondi in {lang}.`, + }, + ar: { + name: 'ترجمة', + content: `أنت مترجم محترف. قم بترجمة المقال التالي وفقاً للمتطلبات التالية: + +1. الحفاظ على المعنى والأسلوب الأصلي +2. استخدام تعبيرات مهنية واصطلاحية +3. ترجمة المصطلحات التقنية بدقة +4. الحفاظ على هيكل الفقرات الأصلي + +يرجى الرد باللغة {lang}.`, + }, + }, + system_key_points: { + en: { + name: 'Key Points', + content: `Extract the main ideas and key information from this article in bullet points following these requirements: + +1. Extract 5-10 key points using concise language +2. Each point should be a complete statement +3. Sort by importance +4. Do not add your own opinions or interpretations + +You should respond in {lang}.`, + }, + zh: { + name: '要点', + content: `从文章中提取要点和关键信息,按照以下要求以列表形式呈现: + +1. 使用简洁语言提取5-10个要点 +2. 每个要点应是完整的陈述 +3. 按重要性排序 +4. 不添加自己的观点或解释 + +你需要使用{lang}进行回复。`, + }, + ja: { + name: '要点', + content: `以下の要件に従って、この記事から主要なアイデアと重要な情報を箇条書きで抽出してください: + +1. 簡潔な言葉で5-10の要点を抽出 +2. 各ポイントは完全な文であること +3. 重要度順に並べる +4. 自分の意見や解釈を加えない + +{lang}で回答してください。`, + }, + ko: { + name: '핵심 요점', + content: `다음 요구사항에 따라 이 기사에서 주요 아이디어와 핵심 정보를 글머리 기호로 추출하세요: + +1. 간결한 언어로 5-10개의 핵심 요점 추출 +2. 각 요점은 완전한 문장이어야 함 +3. 중요도 순으로 정렬 +4. 자신의 의견이나 해석을 추가하지 않음 + +{lang}로 응답해 주세요.`, + }, + es: { + name: 'Puntos Clave', + content: `Extrae las ideas principales y la información clave de este artículo en viñetas siguiendo estos requisitos: + +1. Extrae 5-10 puntos clave usando lenguaje conciso +2. Cada punto debe ser una declaración completa +3. Ordena por importancia +4. No añadas tus propias opiniones o interpretaciones + +Debes responder en {lang}.`, + }, + fr: { + name: 'Points Clés', + content: `Extrayez les idées principales et les informations clés de cet article sous forme de puces en suivant ces exigences : + +1. Extraire 5-10 points clés en utilisant un langage concis +2. Chaque point doit être une déclaration complète +3. Trier par importance +4. Ne pas ajouter vos propres opinions ou interprétations + +Veuillez répondre en {lang}.`, + }, + de: { + name: 'Kernpunkte', + content: `Extrahieren Sie die Hauptideen und Schlüsselinformationen aus diesem Artikel in Aufzählungspunkten gemäß diesen Anforderungen: + +1. 5-10 Kernpunkte in prägnanter Sprache extrahieren +2. Jeder Punkt sollte eine vollständige Aussage sein +3. Nach Wichtigkeit sortieren +4. Keine eigenen Meinungen oder Interpretationen hinzufügen + +Bitte antworten Sie auf {lang}.`, + }, + ru: { + name: 'Ключевые моменты', + content: `Извлеките основные идеи и ключевую информацию из этой статьи в виде маркированного списка, следуя этим требованиям: + +1. Извлеките 5-10 ключевых моментов, используя краткий язык +2. Каждый пункт должен быть полным утверждением +3. Сортируйте по важности +4. Не добавляйте собственные мнения или интерпретации + +Пожалуйста, отвечайте на {lang}.`, + }, + pt: { + name: 'Pontos-Chave', + content: `Extraia as ideias principais e informações-chave deste artigo em marcadores seguindo estes requisitos: + +1. Extraia 5-10 pontos-chave usando linguagem concisa +2. Cada ponto deve ser uma declaração completa +3. Ordene por importância +4. Não adicione suas próprias opiniões ou interpretações + +Por favor, responda em {lang}.`, + }, + it: { + name: 'Punti Chiave', + content: `Estrai le idee principali e le informazioni chiave da questo articolo in punti elenco seguendo questi requisiti: + +1. Estrai 5-10 punti chiave usando un linguaggio conciso +2. Ogni punto deve essere una dichiarazione completa +3. Ordina per importanza +4. Non aggiungere le tue opinioni o interpretazioni + +Per favore rispondi in {lang}.`, + }, + ar: { + name: 'النقاط الرئيسية', + content: `استخرج الأفكار الرئيسية والمعلومات الأساسية من هذا المقال في نقاط وفقاً للمتطلبات التالية: + +1. استخراج 5-10 نقاط رئيسية بلغة موجزة +2. كل نقطة يجب أن تكون بياناً كاملاً +3. الترتيب حسب الأهمية +4. عدم إضافة آرائك أو تفسيراتك الخاصة + +يرجى الرد باللغة {lang}.`, + }, + }, + system_action_items: { + en: { + name: 'Actions', + content: `Extract actionable items from this article following these requirements: + +1. Identify all executable tasks or recommendations mentioned +2. Describe each action item starting with a verb +3. Arrange in logical execution order +4. If possible, mark priority (High/Medium/Low) + +You should respond in {lang}.`, + }, + zh: { + name: '行动项', + content: `从文章中提取可执行的行动项,按照以下要求: + +1. 识别所有提到的可执行任务或建议 +2. 每个行动项以动词开头描述 +3. 按逻辑执行顺序排列 +4. 如果可能,标记优先级(高/中/低) + +你需要使用{lang}进行回复。`, + }, + ja: { + name: 'アクション', + content: `以下の要件に従って、この記事から実行可能な項目を抽出してください: + +1. 言及されているすべての実行可能なタスクや推奨事項を特定 +2. 各アクションアイテムを動詞で始める +3. 論理的な実行順序で並べる +4. 可能であれば優先度を付ける(高/中/低) + +{lang}で回答してください。`, + }, + ko: { + name: '실행 항목', + content: `다음 요구사항에 따라 이 기사에서 실행 가능한 항목을 추출하세요: + +1. 언급된 모든 실행 가능한 작업이나 권장 사항 식별 +2. 각 실행 항목을 동사로 시작하여 설명 +3. 논리적 실행 순서로 배열 +4. 가능하면 우선순위 표시 (높음/중간/낮음) + +{lang}로 응답해 주세요.`, + }, + es: { + name: 'Acciones', + content: `Extrae elementos accionables de este artículo siguiendo estos requisitos: + +1. Identifica todas las tareas ejecutables o recomendaciones mencionadas +2. Describe cada elemento de acción comenzando con un verbo +3. Organiza en orden lógico de ejecución +4. Si es posible, marca la prioridad (Alta/Media/Baja) + +Debes responder en {lang}.`, + }, + fr: { + name: 'Actions', + content: `Extrayez les éléments actionnables de cet article en suivant ces exigences : + +1. Identifier toutes les tâches exécutables ou recommandations mentionnées +2. Décrire chaque élément d'action en commençant par un verbe +3. Organiser dans un ordre d'exécution logique +4. Si possible, indiquer la priorité (Haute/Moyenne/Basse) + +Veuillez répondre en {lang}.`, + }, + de: { + name: 'Aktionen', + content: `Extrahieren Sie umsetzbare Punkte aus diesem Artikel gemäß diesen Anforderungen: + +1. Alle erwähnten ausführbaren Aufgaben oder Empfehlungen identifizieren +2. Jeden Aktionspunkt mit einem Verb beginnend beschreiben +3. In logischer Ausführungsreihenfolge anordnen +4. Wenn möglich, Priorität markieren (Hoch/Mittel/Niedrig) + +Bitte antworten Sie auf {lang}.`, + }, + ru: { + name: 'Действия', + content: `Извлеките действия из этой статьи, следуя этим требованиям: + +1. Определите все упомянутые выполнимые задачи или рекомендации +2. Описывайте каждое действие, начиная с глагола +3. Расположите в логическом порядке выполнения +4. Если возможно, отметьте приоритет (Высокий/Средний/Низкий) + +Пожалуйста, отвечайте на {lang}.`, + }, + pt: { + name: 'Ações', + content: `Extraia itens acionáveis deste artigo seguindo estes requisitos: + +1. Identifique todas as tarefas executáveis ou recomendações mencionadas +2. Descreva cada item de ação começando com um verbo +3. Organize em ordem lógica de execução +4. Se possível, marque a prioridade (Alta/Média/Baixa) + +Por favor, responda em {lang}.`, + }, + it: { + name: 'Azioni', + content: `Estrai elementi azionabili da questo articolo seguendo questi requisiti: + +1. Identifica tutti i compiti eseguibili o le raccomandazioni menzionate +2. Descrivi ogni elemento d'azione iniziando con un verbo +3. Organizza in ordine logico di esecuzione +4. Se possibile, indica la priorità (Alta/Media/Bassa) + +Per favore rispondi in {lang}.`, + }, + ar: { + name: 'الإجراءات', + content: `استخرج العناصر القابلة للتنفيذ من هذا المقال وفقاً للمتطلبات التالية: + +1. تحديد جميع المهام القابلة للتنفيذ أو التوصيات المذكورة +2. وصف كل عنصر إجراء بالبدء بفعل +3. الترتيب بحسب الترتيب المنطقي للتنفيذ +4. إذا أمكن، تحديد الأولوية (عالية/متوسطة/منخفضة) + +يرجى الرد باللغة {lang}.`, + }, + }, + system_explain: { + en: { + name: 'Explain', + content: `Explain the technical content in this article in depth following these requirements: + +1. Explain complex technical concepts in an easy-to-understand way +2. Provide relevant background knowledge +3. Analyze relationships between technologies +4. Clarify any ambiguous parts in the original text + +You should respond in {lang}.`, + }, + zh: { + name: '解释', + content: `深入解释文章中的技术内容,按照以下要求: + +1. 以易于理解的方式解释复杂的技术概念 +2. 提供相关的背景知识 +3. 分析技术之间的关系 +4. 澄清原文中的任何模糊部分 + +你需要使用{lang}进行回复。`, + }, + ja: { + name: '解説', + content: `以下の要件に従って、この記事の技術的な内容を詳しく説明してください: + +1. 複雑な技術概念をわかりやすく説明 +2. 関連する背景知識を提供 +3. 技術間の関係を分析 +4. 原文の曖昧な部分を明確化 + +{lang}で回答してください。`, + }, + ko: { + name: '설명', + content: `다음 요구사항에 따라 이 기사의 기술적 내용을 심층적으로 설명하세요: + +1. 복잡한 기술 개념을 이해하기 쉽게 설명 +2. 관련 배경 지식 제공 +3. 기술 간의 관계 분석 +4. 원문의 모호한 부분 명확화 + +{lang}로 응답해 주세요.`, + }, + es: { + name: 'Explicar', + content: `Explica en profundidad el contenido técnico de este artículo siguiendo estos requisitos: + +1. Explicar conceptos técnicos complejos de manera fácil de entender +2. Proporcionar conocimientos de fondo relevantes +3. Analizar las relaciones entre tecnologías +4. Aclarar cualquier parte ambigua en el texto original + +Debes responder en {lang}.`, + }, + fr: { + name: 'Expliquer', + content: `Expliquez en profondeur le contenu technique de cet article en suivant ces exigences : + +1. Expliquer les concepts techniques complexes de manière facile à comprendre +2. Fournir des connaissances de fond pertinentes +3. Analyser les relations entre les technologies +4. Clarifier les parties ambiguës du texte original + +Veuillez répondre en {lang}.`, + }, + de: { + name: 'Erklären', + content: `Erklären Sie den technischen Inhalt dieses Artikels ausführlich gemäß diesen Anforderungen: + +1. Komplexe technische Konzepte leicht verständlich erklären +2. Relevantes Hintergrundwissen bereitstellen +3. Beziehungen zwischen Technologien analysieren +4. Unklare Teile im Originaltext klären + +Bitte antworten Sie auf {lang}.`, + }, + ru: { + name: 'Объяснение', + content: `Подробно объясните технический контент в этой статье, следуя этим требованиям: + +1. Объяснить сложные технические концепции понятным образом +2. Предоставить соответствующие фоновые знания +3. Проанализировать взаимосвязи между технологиями +4. Прояснить любые неясные части в оригинальном тексте + +Пожалуйста, отвечайте на {lang}.`, + }, + pt: { + name: 'Explicar', + content: `Explique em profundidade o conteúdo técnico deste artigo seguindo estes requisitos: + +1. Explicar conceitos técnicos complexos de forma fácil de entender +2. Fornecer conhecimento de fundo relevante +3. Analisar relações entre tecnologias +4. Esclarecer quaisquer partes ambíguas no texto original + +Por favor, responda em {lang}.`, + }, + it: { + name: 'Spiegare', + content: `Spiega in profondità il contenuto tecnico di questo articolo seguendo questi requisiti: + +1. Spiegare concetti tecnici complessi in modo facile da capire +2. Fornire conoscenze di base rilevanti +3. Analizzare le relazioni tra le tecnologie +4. Chiarire eventuali parti ambigue nel testo originale + +Per favore rispondi in {lang}.`, + }, + ar: { + name: 'شرح', + content: `اشرح المحتوى التقني في هذا المقال بعمق وفقاً للمتطلبات التالية: + +1. شرح المفاهيم التقنية المعقدة بطريقة سهلة الفهم +2. تقديم المعرفة الخلفية ذات الصلة +3. تحليل العلاقات بين التقنيات +4. توضيح أي أجزاء غامضة في النص الأصلي + +يرجى الرد باللغة {lang}.`, + }, + }, + system_bilingual_translate: { + en: { + name: 'Bilingual Translation', + content: `Translate the following Markdown document into {lang} using paragraph-by-paragraph comparison format. + +## Translation Requirements: +1. **Keep all original text**, add the corresponding translation after each paragraph +2. **Maintain original formatting**, including heading levels, list markers, indentation, code blocks, etc. +3. **Ordered lists special handling**: Translation follows directly after the original text, no line break. Format: \`1. Original content 翻译内容\` + +## Specific Rules: +- **Paragraphs**: Add a blank line after the original paragraph, then add the translation +- **Headings**: Add the translated heading with the same level on the next line +- **Unordered lists**: Add the translation with the same indentation on the next line +- **Ordered lists**: Translation follows directly after the original, separated by a space +- **Code blocks**: Keep unchanged, only translate comments +- **Blank lines, images**: Keep unchanged + +## Notes: +- Use fluent and natural expressions, avoid machine translation feel +- Keep technical terms accurate +- Keep code, commands, and paths unchanged +- Do not split original paragraphs into multiple lines`, + }, + zh: { + name: '双语对照翻译', + content: `请将以下 Markdown 文档翻译成{lang},采用段落对照的方式。 + +## 翻译要求: +1. **保留所有原文**,在每段原文后添加对应的翻译 +2. **保持原始格式**,包括标题级别、列表符号、缩进、代码块等 +3. **有序列表特殊处理**:翻译直接跟在原文后面,不换行。格式:\`1. English content 中文翻译\` + +## 具体规则: +- **段落**:原文段落后空一行,然后添加翻译 +- **标题**:原文标题下一行添加相同级别的翻译标题 +- **无序列表**:原文项下一行添加相同缩进的翻译 +- **有序列表**:翻译直接跟在原文后,用空格分隔 +- **代码块**:保持不变,仅翻译注释 +- **空行、图片**:保持不变 + +## 注意: +- 使用流畅自然的表达,避免机翻感 +- 技术术语保持准确性 +- 代码、命令、路径保持原样 +- 不要将原文的段落拆分为多行`, + }, + ja: { + name: '対訳翻訳', + content: `以下のMarkdownドキュメントを{lang}に翻訳し、段落ごとの対照形式で表示してください。 + +## 翻訳要件: +1. **すべての原文を保持**し、各段落の後に対応する翻訳を追加 +2. **元のフォーマットを維持**、見出しレベル、リストマーカー、インデント、コードブロックなどを含む +3. **番号付きリストの特別処理**:翻訳は原文の直後に続け、改行しない。形式:\`1. Original content 翻訳内容\` + +## 具体的なルール: +- **段落**:原文段落の後に空行を入れ、翻訳を追加 +- **見出し**:原文見出しの次の行に同じレベルの翻訳見出しを追加 +- **箇条書き**:原文項目の次の行に同じインデントで翻訳を追加 +- **番号付きリスト**:翻訳は原文の直後にスペースで区切って続ける +- **コードブロック**:変更せず、コメントのみ翻訳 +- **空行、画像**:変更しない + +## 注意: +- 流暢で自然な表現を使用し、機械翻訳感を避ける +- 技術用語の正確性を保つ +- コード、コマンド、パスはそのまま維持 +- 原文の段落を複数行に分割しない`, + }, + ko: { + name: '이중 언어 번역', + content: `다음 Markdown 문서를 {lang}로 번역하고, 단락별 대조 형식으로 표시하세요. + +## 번역 요구사항: +1. **모든 원문 유지**, 각 단락 뒤에 해당 번역 추가 +2. **원본 서식 유지**, 제목 수준, 목록 기호, 들여쓰기, 코드 블록 등 포함 +3. **순서 목록 특별 처리**: 번역은 원문 바로 뒤에 이어서, 줄 바꿈 없음. 형식: \`1. Original content 번역 내용\` + +## 구체적인 규칙: +- **단락**: 원문 단락 뒤에 빈 줄을 넣고 번역 추가 +- **제목**: 원문 제목 다음 줄에 같은 수준의 번역 제목 추가 +- **비순서 목록**: 원문 항목 다음 줄에 같은 들여쓰기로 번역 추가 +- **순서 목록**: 번역은 원문 바로 뒤에 공백으로 구분하여 이어서 +- **코드 블록**: 변경하지 않고 주석만 번역 +- **빈 줄, 이미지**: 변경하지 않음 + +## 주의: +- 유창하고 자연스러운 표현 사용, 기계 번역 느낌 피하기 +- 기술 용어의 정확성 유지 +- 코드, 명령어, 경로는 그대로 유지 +- 원문 단락을 여러 줄로 분할하지 않음`, + }, + es: { + name: 'Traducción Bilingüe', + content: `Traduce el siguiente documento Markdown a {lang} usando formato de comparación párrafo por párrafo. + +## Requisitos de traducción: +1. **Mantener todo el texto original**, agregar la traducción correspondiente después de cada párrafo +2. **Mantener el formato original**, incluyendo niveles de encabezado, marcadores de lista, sangría, bloques de código, etc. +3. **Manejo especial de listas ordenadas**: La traducción sigue directamente después del texto original, sin salto de línea. Formato: \`1. Contenido original Traducción\` + +## Reglas específicas: +- **Párrafos**: Agregar una línea en blanco después del párrafo original, luego agregar la traducción +- **Encabezados**: Agregar el encabezado traducido con el mismo nivel en la siguiente línea +- **Listas no ordenadas**: Agregar la traducción con la misma sangría en la siguiente línea +- **Listas ordenadas**: La traducción sigue directamente después del original, separada por un espacio +- **Bloques de código**: Mantener sin cambios, solo traducir comentarios +- **Líneas en blanco, imágenes**: Mantener sin cambios + +## Notas: +- Usar expresiones fluidas y naturales, evitar sensación de traducción automática +- Mantener la precisión de los términos técnicos +- Mantener código, comandos y rutas sin cambios +- No dividir los párrafos originales en múltiples líneas`, + }, + fr: { + name: 'Traduction Bilingue', + content: `Traduisez le document Markdown suivant en {lang} en utilisant un format de comparaison paragraphe par paragraphe. + +## Exigences de traduction : +1. **Conserver tout le texte original**, ajouter la traduction correspondante après chaque paragraphe +2. **Maintenir le formatage original**, y compris les niveaux de titre, les marqueurs de liste, l'indentation, les blocs de code, etc. +3. **Traitement spécial des listes ordonnées** : La traduction suit directement le texte original, sans saut de ligne. Format : \`1. Contenu original Traduction\` + +## Règles spécifiques : +- **Paragraphes** : Ajouter une ligne vide après le paragraphe original, puis ajouter la traduction +- **Titres** : Ajouter le titre traduit avec le même niveau sur la ligne suivante +- **Listes non ordonnées** : Ajouter la traduction avec la même indentation sur la ligne suivante +- **Listes ordonnées** : La traduction suit directement l'original, séparée par un espace +- **Blocs de code** : Garder inchangés, traduire uniquement les commentaires +- **Lignes vides, images** : Garder inchangées + +## Notes : +- Utiliser des expressions fluides et naturelles, éviter l'impression de traduction automatique +- Maintenir la précision des termes techniques +- Garder le code, les commandes et les chemins inchangés +- Ne pas diviser les paragraphes originaux en plusieurs lignes`, + }, + de: { + name: 'Zweisprachige Übersetzung', + content: `Übersetzen Sie das folgende Markdown-Dokument in {lang} im Absatz-für-Absatz-Vergleichsformat. + +## Übersetzungsanforderungen: +1. **Gesamten Originaltext beibehalten**, nach jedem Absatz die entsprechende Übersetzung hinzufügen +2. **Originalformatierung beibehalten**, einschließlich Überschriftenebenen, Listenmarkierungen, Einrückung, Codeblöcke usw. +3. **Spezielle Behandlung geordneter Listen**: Übersetzung folgt direkt nach dem Originaltext, kein Zeilenumbruch. Format: \`1. Originalinhalt Übersetzung\` + +## Spezifische Regeln: +- **Absätze**: Nach dem Originalabsatz eine Leerzeile einfügen, dann die Übersetzung hinzufügen +- **Überschriften**: Übersetzte Überschrift mit gleicher Ebene in der nächsten Zeile hinzufügen +- **Ungeordnete Listen**: Übersetzung mit gleicher Einrückung in der nächsten Zeile hinzufügen +- **Geordnete Listen**: Übersetzung folgt direkt nach dem Original, durch Leerzeichen getrennt +- **Codeblöcke**: Unverändert lassen, nur Kommentare übersetzen +- **Leerzeilen, Bilder**: Unverändert lassen + +## Hinweise: +- Flüssige und natürliche Ausdrücke verwenden, maschinellen Übersetzungseindruck vermeiden +- Genauigkeit der Fachbegriffe beibehalten +- Code, Befehle und Pfade unverändert lassen +- Originalabsätze nicht in mehrere Zeilen aufteilen`, + }, + ru: { + name: 'Двуязычный перевод', + content: `Переведите следующий Markdown-документ на {lang}, используя формат сравнения абзац за абзацем. + +## Требования к переводу: +1. **Сохранить весь оригинальный текст**, добавить соответствующий перевод после каждого абзаца +2. **Сохранить оригинальное форматирование**, включая уровни заголовков, маркеры списков, отступы, блоки кода и т.д. +3. **Специальная обработка нумерованных списков**: Перевод следует сразу после оригинального текста, без переноса строки. Формат: \`1. Оригинальный контент Перевод\` + +## Конкретные правила: +- **Абзацы**: После оригинального абзаца добавить пустую строку, затем добавить перевод +- **Заголовки**: Добавить переведённый заголовок того же уровня на следующей строке +- **Маркированные списки**: Добавить перевод с тем же отступом на следующей строке +- **Нумерованные списки**: Перевод следует сразу после оригинала, разделённый пробелом +- **Блоки кода**: Оставить без изменений, переводить только комментарии +- **Пустые строки, изображения**: Оставить без изменений + +## Примечания: +- Использовать плавные и естественные выражения, избегать ощущения машинного перевода +- Сохранять точность технических терминов +- Оставлять код, команды и пути без изменений +- Не разбивать оригинальные абзацы на несколько строк`, + }, + pt: { + name: 'Tradução Bilíngue', + content: `Traduza o seguinte documento Markdown para {lang} usando formato de comparação parágrafo por parágrafo. + +## Requisitos de tradução: +1. **Manter todo o texto original**, adicionar a tradução correspondente após cada parágrafo +2. **Manter a formatação original**, incluindo níveis de título, marcadores de lista, recuo, blocos de código, etc. +3. **Tratamento especial de listas ordenadas**: A tradução segue diretamente após o texto original, sem quebra de linha. Formato: \`1. Conteúdo original Tradução\` + +## Regras específicas: +- **Parágrafos**: Adicionar uma linha em branco após o parágrafo original, depois adicionar a tradução +- **Títulos**: Adicionar o título traduzido com o mesmo nível na linha seguinte +- **Listas não ordenadas**: Adicionar a tradução com o mesmo recuo na linha seguinte +- **Listas ordenadas**: A tradução segue diretamente após o original, separada por um espaço +- **Blocos de código**: Manter inalterados, traduzir apenas comentários +- **Linhas em branco, imagens**: Manter inalteradas + +## Notas: +- Usar expressões fluentes e naturais, evitar sensação de tradução automática +- Manter a precisão dos termos técnicos +- Manter código, comandos e caminhos inalterados +- Não dividir os parágrafos originais em múltiplas linhas`, + }, + it: { + name: 'Traduzione Bilingue', + content: `Traduci il seguente documento Markdown in {lang} usando il formato di confronto paragrafo per paragrafo. + +## Requisiti di traduzione: +1. **Mantenere tutto il testo originale**, aggiungere la traduzione corrispondente dopo ogni paragrafo +2. **Mantenere la formattazione originale**, inclusi livelli di intestazione, marcatori di elenco, rientro, blocchi di codice, ecc. +3. **Gestione speciale degli elenchi ordinati**: La traduzione segue direttamente il testo originale, senza interruzione di riga. Formato: \`1. Contenuto originale Traduzione\` + +## Regole specifiche: +- **Paragrafi**: Aggiungere una riga vuota dopo il paragrafo originale, poi aggiungere la traduzione +- **Intestazioni**: Aggiungere l'intestazione tradotta con lo stesso livello sulla riga successiva +- **Elenchi non ordinati**: Aggiungere la traduzione con lo stesso rientro sulla riga successiva +- **Elenchi ordinati**: La traduzione segue direttamente l'originale, separata da uno spazio +- **Blocchi di codice**: Mantenere invariati, tradurre solo i commenti +- **Righe vuote, immagini**: Mantenere invariate + +## Note: +- Usare espressioni fluide e naturali, evitare la sensazione di traduzione automatica +- Mantenere l'accuratezza dei termini tecnici +- Mantenere codice, comandi e percorsi invariati +- Non dividere i paragrafi originali in più righe`, + }, + ar: { + name: 'ترجمة ثنائية اللغة', + content: `ترجم مستند Markdown التالي إلى {lang} باستخدام تنسيق المقارنة فقرة بفقرة. + +## متطلبات الترجمة: +1. **الاحتفاظ بكل النص الأصلي**، إضافة الترجمة المقابلة بعد كل فقرة +2. **الحفاظ على التنسيق الأصلي**، بما في ذلك مستويات العناوين وعلامات القوائم والمسافات البادئة وكتل الكود وما إلى ذلك +3. **معالجة خاصة للقوائم المرقمة**: الترجمة تتبع مباشرة بعد النص الأصلي، بدون سطر جديد. التنسيق: \`1. المحتوى الأصلي الترجمة\` + +## القواعد المحددة: +- **الفقرات**: إضافة سطر فارغ بعد الفقرة الأصلية، ثم إضافة الترجمة +- **العناوين**: إضافة العنوان المترجم بنفس المستوى في السطر التالي +- **القوائم غير المرقمة**: إضافة الترجمة بنفس المسافة البادئة في السطر التالي +- **القوائم المرقمة**: الترجمة تتبع مباشرة بعد الأصل، مفصولة بمسافة +- **كتل الكود**: تبقى دون تغيير، ترجمة التعليقات فقط +- **الأسطر الفارغة، الصور**: تبقى دون تغيير + +## ملاحظات: +- استخدام تعبيرات سلسة وطبيعية، تجنب الشعور بالترجمة الآلية +- الحفاظ على دقة المصطلحات التقنية +- الحفاظ على الكود والأوامر والمسارات دون تغيير +- عدم تقسيم الفقرات الأصلية إلى أسطر متعددة`, + }, + }, +}; + +// Get language code from language English name (for system prompt lookup) +function getLanguageCode(languageEnglish: string): string { + const lang = LANGUAGES.find(l => l.english.toLowerCase() === languageEnglish.toLowerCase()); + if (!lang) return 'en'; + + // Map Chinese variants to 'zh' for system prompt lookup + if (lang.code.startsWith('zh')) { + return 'zh'; + } + return lang.code; +} + +// Get localized system prompt content +export function getLocalizedSystemPrompt(promptId: string, targetLanguage: string): SystemPromptContent { + const translations = SYSTEM_PROMPT_TRANSLATIONS[promptId]; + if (!translations) { + return { name: 'Unknown', content: '' }; + } + + const langCode = getLanguageCode(targetLanguage); + return translations[langCode] || translations['en'] || { name: 'Unknown', content: '' }; +} \ No newline at end of file diff --git a/app/extension/src/ai/types.ts b/app/extension/src/ai/types.ts new file mode 100644 index 00000000..67eda233 --- /dev/null +++ b/app/extension/src/ai/types.ts @@ -0,0 +1,301 @@ +/** + * AI Provider Types and Interfaces + */ + +// Supported AI provider types +export type ProviderType = + | 'openai' + | 'anthropic' + | 'google' + | 'deepseek' + | 'zhipu' + | 'minimax' + | 'groq' + | 'ollama' + | 'azure-openai' + | 'azure-ai' + | 'huntly-server'; + +// AI Provider configuration +export interface AIProviderConfig { + type: ProviderType; + apiKey: string; + baseUrl?: string; + enabledModels: string[]; + enabled: boolean; + updatedAt: number; +} + +// All AI providers storage structure +export interface AIProvidersStorage { + providers: Record; + defaultProvider: ProviderType | null; +} + +// Provider metadata for UI display +export interface ProviderMeta { + type: ProviderType; + displayName: string; + description: string; + icon: string; + requiresApiKey: boolean; + supportsCustomUrl: boolean; + defaultBaseUrl: string; + defaultModels: ModelInfo[]; +} + +// Model information +export interface ModelInfo { + id: string; + name?: string; // Optional display name, defaults to id if not provided + description?: string; +} + +// Connection test result +export interface ConnectionTestResult { + success: boolean; + message: string; + models?: string[]; +} + +// Provider metadata registry +export const PROVIDER_REGISTRY: Record = { + openai: { + type: 'openai', + displayName: 'OpenAI', + description: 'GPT-5.2, GPT-4.1, o3, o4-mini and more', + icon: 'openai', + requiresApiKey: true, + supportsCustomUrl: true, + defaultBaseUrl: 'https://api.openai.com/v1', + defaultModels: [ + { id: 'gpt-5.2' }, + { id: 'gpt-5' }, + { id: 'gpt-5-mini' }, + { id: 'o4-mini' }, + { id: 'o3-pro' }, + { id: 'o3' }, + { id: 'o3-mini' }, + { id: 'gpt-4.1' }, + { id: 'gpt-4.1-mini' }, + { id: 'gpt-4.1-nano' }, + { id: 'gpt-4o' }, + { id: 'gpt-4o-mini' }, + ], + }, + deepseek: { + type: 'deepseek', + displayName: 'DeepSeek', + description: 'DeepSeek V3.2, R1, V3 and more', + icon: 'deepseek', + requiresApiKey: true, + supportsCustomUrl: true, + defaultBaseUrl: 'https://api.deepseek.com', + defaultModels: [ + { id: 'deepseek-v3.2' }, + { id: 'deepseek-v3.1' }, + { id: 'deepseek-r1-0528' }, + { id: 'deepseek-v3-0324' }, + { id: 'deepseek-reasoner' }, + { id: 'deepseek-chat' }, + ], + }, + groq: { + type: 'groq', + displayName: 'Groq', + description: 'Ultra-fast inference with Llama, Qwen, DeepSeek', + icon: 'groq', + requiresApiKey: true, + supportsCustomUrl: false, + defaultBaseUrl: 'https://api.groq.com/openai/v1', + defaultModels: [ + { id: 'groq/compound-mini' }, + { id: 'qwen/qwen3-32b' }, + { id: 'qwen-qwq-32b' }, + { id: 'llama-3.3-70b-versatile' }, + { id: 'deepseek-r1-distill-llama-70b' }, + { id: 'llama-3.1-8b-instant' }, + { id: 'qwen-2.5-32b' }, + { id: 'llama3-70b-8192' }, + { id: 'gemma2-9b-it' }, + ], + }, + google: { + type: 'google', + displayName: 'Google (Gemini)', + description: 'Gemini 3 Pro/Flash, 2.5 Pro/Flash', + icon: 'google', + requiresApiKey: true, + supportsCustomUrl: true, + defaultBaseUrl: 'https://generativelanguage.googleapis.com/v1beta', + defaultModels: [ + { id: 'gemini-3-pro-preview' }, + { id: 'gemini-3-flash' }, + { id: 'gemini-2.5-pro' }, + { id: 'gemini-2.5-pro-preview' }, + { id: 'gemini-2.5-flash' }, + { id: 'gemini-2.5-flash-preview' }, + { id: 'gemini-2.5-flash-lite' }, + ], + }, + ollama: { + type: 'ollama', + displayName: 'Ollama', + description: 'Run local models on your machine', + icon: 'ollama', + requiresApiKey: false, + supportsCustomUrl: true, + defaultBaseUrl: 'http://localhost:11434', + defaultModels: [ + { id: 'qwq:32b' }, + { id: 'phi4' }, + { id: 'gemma3' }, + { id: 'deepseek-r1:14b' }, + { id: 'qwen3:8b' }, + { id: 'llama3.3' }, + { id: 'llama3.2' }, + { id: 'deepseek-coder-v2' }, + { id: 'mistral' }, + { id: 'codellama' }, + ], + }, + anthropic: { + type: 'anthropic', + displayName: 'Anthropic (Claude)', + description: 'Claude Opus 4.5, Sonnet 4.5, Haiku 4.5 and more', + icon: 'anthropic', + requiresApiKey: true, + supportsCustomUrl: true, + defaultBaseUrl: 'https://api.anthropic.com/v1', + defaultModels: [ + { id: 'claude-opus-4-5-20250929' }, + { id: 'claude-sonnet-4-5-20250929' }, + { id: 'claude-haiku-4-5-20251015' }, + { id: 'claude-opus-4-20250514' }, + { id: 'claude-sonnet-4-20250514' }, + { id: 'claude-3-5-sonnet-20241022' }, + { id: 'claude-3-5-haiku-20241022' }, + ], + }, + 'azure-openai': { + type: 'azure-openai', + displayName: 'Azure OpenAI', + description: 'OpenAI models on Azure', + icon: 'azure', + requiresApiKey: true, + supportsCustomUrl: true, + defaultBaseUrl: '', + defaultModels: [ + { id: 'gpt-5.2' }, + { id: 'gpt-5' }, + { id: 'gpt-5-mini' }, + { id: 'o4-mini' }, + { id: 'o3' }, + { id: 'gpt-4.1' }, + { id: 'gpt-4o' }, + { id: 'gpt-4o-mini' }, + ], + }, + 'azure-ai': { + type: 'azure-ai', + displayName: 'Azure AI', + description: 'Azure AI Foundry models', + icon: 'azure', + requiresApiKey: true, + supportsCustomUrl: true, + defaultBaseUrl: '', + defaultModels: [ + { id: 'Mistral-small-2503' }, + { id: 'DeepSeek-V3' }, + { id: 'DeepSeek-R1' }, + { id: 'Llama-3.3-70B-Instruct' }, + { id: 'Phi-4' }, + { id: 'Phi-4-mini' }, + { id: 'Mistral-large-2411' }, + ], + }, + zhipu: { + type: 'zhipu', + displayName: 'Zhipu AI (智谱)', + description: 'GLM-4.7, GLM-Z1 and more', + icon: 'zhipu', + requiresApiKey: true, + supportsCustomUrl: true, + defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', + defaultModels: [ + { id: 'glm-4.7' }, + { id: 'glm-4.6' }, + { id: 'glm-4.5' }, + { id: 'glm-z1-rumination' }, + { id: 'glm-z1-32b' }, + { id: 'glm-z1-airx' }, + { id: 'glm-z1-air' }, + { id: 'glm-z1-flash' }, + { id: 'glm-z1-9b' }, + { id: 'glm-4-flashx' }, + ], + }, + minimax: { + type: 'minimax', + displayName: 'MiniMax', + description: 'MiniMax M2, M1, Text-01 and more', + icon: 'minimax', + requiresApiKey: true, + supportsCustomUrl: true, + defaultBaseUrl: 'https://api.minimax.chat/v1', + defaultModels: [ + { id: 'minimax-m2.1' }, + { id: 'minimax-m2' }, + { id: 'minimax-m1' }, + { id: 'MiniMax-VL-01' }, + { id: 'MiniMax-Text-01' }, + { id: 'abab6.5s-chat' }, + { id: 'abab6.5g-chat' }, + ], + }, + 'huntly-server': { + type: 'huntly-server', + displayName: 'Huntly Server', + description: 'Use AI configured on your Huntly server', + icon: 'huntly', + requiresApiKey: false, + supportsCustomUrl: false, + defaultBaseUrl: '', + defaultModels: [ + { id: 'server-default' }, + ], + }, +}; + +// Get all provider types in display order (Huntly Server first, then by influence/popularity) +export const PROVIDER_ORDER: ProviderType[] = [ + 'huntly-server', + 'openai', + 'anthropic', + 'google', + 'deepseek', + 'zhipu', + 'minimax', + 'groq', + 'ollama', + 'azure-openai', + 'azure-ai', +]; + +// Default empty storage +export const DEFAULT_AI_STORAGE: AIProvidersStorage = { + providers: { + openai: null, + anthropic: null, + google: null, + deepseek: null, + zhipu: null, + minimax: null, + groq: null, + ollama: null, + 'azure-openai': null, + 'azure-ai': null, + 'huntly-server': null, + }, + defaultProvider: null, +}; diff --git a/app/extension/src/background.ts b/app/extension/src/background.ts index aaabc0af..d3cb132a 100644 --- a/app/extension/src/background.ts +++ b/app/extension/src/background.ts @@ -1,7 +1,31 @@ import {log} from "./logger"; -import {readSyncStorageSettings} from "./storage"; -import {autoSaveArticle, saveArticle, sendData} from "./services"; +import {readSyncStorageSettings, getPromptsSettings, getLanguageNativeName} from "./storage"; +import {autoSaveArticle, saveArticle, sendData, fetchEnabledShortcuts, getApiBaseUrl} from "./services"; import {sseRequestManager} from "./sseTaskManager"; +import { + getAIProvidersStorage, + getAvailableProviderTypes, + getEffectiveDefaultProviderType, +} from "./ai/storage"; +import { PROVIDER_REGISTRY, ProviderType } from "./ai/types"; +import { createProviderModel } from "./ai/providers"; +import { streamText } from "ai"; +// Note: turndown is not used here because service worker has no DOM +// HTML to markdown conversion should be done in content script/popup before sending to background + +// Store AbortControllers for Vercel AI tasks +const vercelAIAbortControllers = new Map(); + +// Cancel a Vercel AI task +function cancelVercelAITask(taskId: string): boolean { + const controller = vercelAIAbortControllers.get(taskId); + if (controller) { + controller.abort(); + vercelAIAbortControllers.delete(taskId); + return true; + } + return false; +} function startProcessingWithShortcuts(task: any, shortcuts: any[]) { if (!task) return; @@ -108,6 +132,148 @@ function startProcessingWithShortcuts(task: any, shortcuts: any[]) { }); } +// Prepare markdown content with title prefix +function prepareMarkdownContent(markdownContent: string, title?: string): string { + // If title exists, add it to the content beginning + if (title && title.trim()) { + return `# ${title}\n\n${markdownContent}`; + } + return markdownContent; +} + +// Process content with Vercel AI SDK (for non-Huntly models) +async function startProcessingWithVercelAI(task: any) { + if (!task) return; + + const { tabId, taskId, shortcutName, shortcutContent, content, title, selectedModel } = task; + + // Create AbortController for this task + const abortController = new AbortController(); + vercelAIAbortControllers.set(taskId, abortController); + + // Send processing start message + chrome.tabs.sendMessage(tabId, { + type: 'shortcuts_processing_start', + payload: { + title: shortcutName, + shortcutName: shortcutName, + taskId: taskId + } + }); + + try { + // Get provider config + const storage = await getAIProvidersStorage(); + const providerType = selectedModel.provider as ProviderType; + const config = storage.providers[providerType]; + + if (!config || !config.enabled) { + throw new Error(`Provider ${providerType} is not configured or enabled`); + } + + // Extract model ID from the selectedModel.id (format: "provider:modelId") + const modelId = selectedModel.id.split(':').slice(1).join(':'); + + // Create the model + const model = createProviderModel(config, modelId); + if (!model) { + throw new Error(`Failed to create model for ${providerType}`); + } + + // Get default target language for {lang} replacement + const promptsSettings = await getPromptsSettings(); + const defaultTargetLanguage = promptsSettings.defaultTargetLanguage || 'English'; + + // Build the prompt: replace {lang} placeholder with native language name + const nativeLanguageName = getLanguageNativeName(defaultTargetLanguage); + const systemPrompt = (shortcutContent || '').replace(/\{lang\}/g, nativeLanguageName); + + // Prepare user prompt: content is already markdown (converted in ArticlePreview), add title prefix + const userPrompt = prepareMarkdownContent(content, title); + + let accumulatedContent = ""; + + // Use streamText for streaming response with abort signal + const result = await streamText({ + model, + system: systemPrompt, + prompt: userPrompt, + maxTokens: 8000, + abortSignal: abortController.signal, + }); + + // Process the stream + for await (const textPart of result.textStream) { + // Check if aborted + if (abortController.signal.aborted) { + break; + } + + accumulatedContent += textPart; + + // Send streaming data to preview + try { + chrome.tabs.sendMessage(tabId, { + type: 'shortcuts_process_data', + payload: { + data: textPart, + accumulatedContent: accumulatedContent, + title: shortcutName, + taskId: taskId + } + }); + } catch (error) { + console.warn("Failed to send shortcuts_process_data message:", error); + break; + } + } + + // Clean up AbortController + vercelAIAbortControllers.delete(taskId); + + // Only send completion if not aborted + if (!abortController.signal.aborted) { + try { + chrome.tabs.sendMessage(tabId, { + type: 'shortcuts_process_result', + payload: { + content: accumulatedContent, + title: shortcutName, + taskId: taskId + } + }); + } catch (error) { + console.warn("Failed to send shortcuts_process_result message:", error); + } + } + + } catch (error: any) { + // Clean up AbortController + vercelAIAbortControllers.delete(taskId); + + // Don't report error if it was aborted + if (abortController.signal.aborted) { + log("Task was cancelled:", taskId); + return; + } + + console.error("Error processing with Vercel AI SDK for task:", taskId, error); + + try { + chrome.tabs.sendMessage(tabId, { + type: 'shortcuts_process_error', + payload: { + error: error.message || 'Processing failed', + title: shortcutName, + taskId: taskId + } + }); + } catch (sendError) { + console.warn("Failed to send shortcuts_process_error message:", sendError); + } + } +} + chrome.runtime.onMessage.addListener(function (msg: Message, sender, sendResponse) { if (msg.type === "auto_save_clipper") { autoSaveArticle(msg.payload).then(handleSaveArticleResponse); @@ -116,37 +282,170 @@ chrome.runtime.onMessage.addListener(function (msg: Message, sender, sendRespons } else if (msg.type === 'auto_save_tweets') { readSyncStorageSettings().then((settings) => { if (settings.autoSaveTweet) { - // Add minLikes to payload - msg.payload.minLikes = settings.autoSaveTweetMinLikes; sendData("tweet/saveTweets", msg.payload); } }); } else if (msg.type === 'read_tweet') { sendData("tweet/trackRead", msg.payload); } else if (msg.type === 'shortcuts_process') { - const task = { - tabId: msg.payload.tabId || sender.tab?.id, - taskId: msg.payload.taskId, - shortcutId: msg.payload.shortcutId, - shortcutName: msg.payload.shortcutName, - content: msg.payload.content, - url: msg.payload.url, - title: msg.payload.title || "", // 文章标题 - contentType: msg.payload.contentType, // Pass contentType for snippet mode - }; - - // 使用 payload 中传递的 shortcuts 数据 - const shortcuts = msg.payload.shortcuts || []; - startProcessingWithShortcuts(task, shortcuts); + const selectedModel = msg.payload.selectedModel; + const isHuntlyServer = selectedModel?.provider === 'huntly-server'; + + if (isHuntlyServer) { + // Use Huntly Server SSE API + const task = { + tabId: msg.payload.tabId || sender.tab?.id, + taskId: msg.payload.taskId, + shortcutId: msg.payload.shortcutId, + shortcutName: msg.payload.shortcutName, + content: msg.payload.content, + url: msg.payload.url, + title: msg.payload.title || "", + contentType: msg.payload.contentType, + }; + const shortcuts = msg.payload.shortcuts || []; + startProcessingWithShortcuts(task, shortcuts); + } else { + // Use Vercel AI SDK for other providers + const task = { + tabId: msg.payload.tabId || sender.tab?.id, + taskId: msg.payload.taskId, + shortcutName: msg.payload.shortcutName, + shortcutContent: msg.payload.shortcutContent, // The prompt content + content: msg.payload.content, + url: msg.payload.url, + title: msg.payload.title || "", + contentType: msg.payload.contentType, + selectedModel: selectedModel, + }; + startProcessingWithVercelAI(task); + } } else if (msg.type === 'shortcuts_cancel') { // 根据 taskId 取消处理任务 const taskId = msg.payload.taskId; - if (sseRequestManager.cancelTask(taskId)) { + // Try to cancel SSE task (Huntly server) or Vercel AI task + const sseCancelled = sseRequestManager.cancelTask(taskId); + const vercelCancelled = cancelVercelAITask(taskId); + if (sseCancelled || vercelCancelled) { log("Processing cancelled for task:", taskId); } + } else if (msg.type === 'get_huntly_shortcuts') { + // Fetch huntly shortcuts from the server (for content script use) + fetchEnabledShortcuts() + .then((shortcuts) => { + sendResponse({ success: true, shortcuts: shortcuts || [] }); + }) + .catch((error) => { + console.error('Failed to fetch huntly shortcuts:', error); + sendResponse({ success: false, shortcuts: [], error: error.message }); + }); + return true; // Keep the message channel open for async response + } else if (msg.type === 'get_ai_toolbar_data') { + // Get all AI toolbar data for content script use (shortcuts + models) + getAIToolbarData() + .then((data) => { + sendResponse({ success: true, ...data }); + }) + .catch((error) => { + console.error('Failed to get AI toolbar data:', error); + sendResponse({ success: false, error: error.message }); + }); + return true; // Keep the message channel open for async response + } else if ((msg as any).type === 'open_tab') { + // Open a new tab (for content script context where chrome.tabs.create is not available) + const openTabMsg = msg as Message & { url?: string }; + const url = openTabMsg.url || openTabMsg.payload?.url; + if (url) { + chrome.tabs.create({ url }); + } } }); +// Helper function to get all AI toolbar data +async function getAIToolbarData() { + // Load shortcuts data + const promptsSettings = await getPromptsSettings(); + const enabledPrompts = promptsSettings.prompts.filter(p => p.enabled); + const userPrompts = enabledPrompts.filter(p => !p.isSystem); + const systemPrompts = enabledPrompts.filter(p => p.isSystem); + const huntlyShortcutsEnabled = promptsSettings.huntlyShortcutsEnabled; + + // Load huntly shortcuts if enabled + let huntlyShortcuts: any[] = []; + const baseUrl = await getApiBaseUrl(); + if (baseUrl && huntlyShortcutsEnabled) { + try { + huntlyShortcuts = await fetchEnabledShortcuts(); + } catch (error) { + console.error('Failed to fetch huntly shortcuts:', error); + } + } + + // Load models data + interface ModelItem { + id: string; + name: string; + provider: string; + providerName: string; + } + const modelList: ModelItem[] = []; + const storage = await getAIProvidersStorage(); + const availableProviders = await getAvailableProviderTypes(); + + // Add Huntly Server models first (only if huntlyShortcutsEnabled) + if (baseUrl && huntlyShortcutsEnabled) { + modelList.push({ + id: 'huntly-server:default', + name: 'Huntly AI', + provider: 'huntly-server', + providerName: 'Huntly', + }); + } + + // Add models from enabled providers + for (const providerType of availableProviders) { + if (providerType === 'huntly-server') continue; + + const config = storage.providers[providerType]; + if (config?.enabled && config.enabledModels.length > 0) { + const providerMeta = PROVIDER_REGISTRY[providerType]; + for (const modelId of config.enabledModels) { + const modelMeta = providerMeta.defaultModels.find(m => m.id === modelId); + modelList.push({ + id: `${providerType}:${modelId}`, + name: modelMeta?.name || modelId, + provider: providerType, + providerName: providerMeta.displayName, + }); + } + } + } + + // Determine default model + let defaultModel: ModelItem | null = null; + if (modelList.length > 0) { + const defaultProviderType = await getEffectiveDefaultProviderType(); + if (defaultProviderType) { + defaultModel = modelList.find(m => m.provider === defaultProviderType) || modelList[0]; + } else { + defaultModel = modelList[0]; + } + } + + return { + externalShortcuts: { + userPrompts, + systemPrompts, + huntlyShortcuts, + huntlyShortcutsEnabled, + }, + externalModels: { + models: modelList, + defaultModel, + }, + }; +} + function handleSaveArticleResponse(resp: string) { log("save article result", resp); if (resp) { diff --git a/app/extension/src/components/AIProviderDialog.tsx b/app/extension/src/components/AIProviderDialog.tsx new file mode 100644 index 00000000..60669bfb --- /dev/null +++ b/app/extension/src/components/AIProviderDialog.tsx @@ -0,0 +1,542 @@ +import React, { useEffect, useState } from 'react'; +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + IconButton, + InputAdornment, + List, + ListItem, + ListItemText, + Switch, + TextField, + Typography, +} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import { + AIProviderConfig, + ConnectionTestResult, + ModelInfo, + ProviderType, + PROVIDER_REGISTRY, +} from '../ai/types'; +import { + deleteProviderConfig, + getProviderConfig, + saveProviderConfig, +} from '../ai/storage'; +import { fetchOllamaModels, testProviderConnection } from '../ai/providers'; + +export interface AIProviderDialogProps { + open: boolean; + providerType: ProviderType; + onClose: () => void; +} + +export const AIProviderDialog: React.FC = ({ + open, + providerType, + onClose, +}) => { + const meta = PROVIDER_REGISTRY[providerType]; + + const [apiKey, setApiKey] = useState(''); + const [baseUrl, setBaseUrl] = useState(''); + const [showApiKey, setShowApiKey] = useState(false); + const [providerEnabled, setProviderEnabled] = useState(true); + const [enabledModels, setEnabledModels] = useState([]); + const [availableModels, setAvailableModels] = useState( + meta.defaultModels + ); + const [customModels, setCustomModels] = useState([]); + const [newModelName, setNewModelName] = useState(''); + const [showAddModel, setShowAddModel] = useState(false); + const [editingModelIndex, setEditingModelIndex] = useState(null); + const [editingModelName, setEditingModelName] = useState(''); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState( + null + ); + const [saving, setSaving] = useState(false); + const [loadingModels, setLoadingModels] = useState(false); + + // Get all preset model IDs for duplicate checking + const presetModelIds = new Set(availableModels.map((m) => m.id)); + + useEffect(() => { + if (open) { + loadConfig(); + } + }, [open, providerType]); + + const loadConfig = async () => { + const config = await getProviderConfig(providerType); + const defaultPresetModels = meta.defaultModels; + const presetIds = new Set(defaultPresetModels.map((m) => m.id)); + + let configBaseUrl = ''; + if (config) { + setApiKey(config.apiKey); + // Only set baseUrl if it was explicitly saved (not the default) + configBaseUrl = config.baseUrl || ''; + setBaseUrl(configBaseUrl); + setProviderEnabled(config.enabled); + // Separate custom models from preset models + const custom = config.enabledModels.filter((id) => !presetIds.has(id)); + const preset = config.enabledModels.filter((id) => presetIds.has(id)); + setEnabledModels(preset); + setCustomModels(custom); + } else { + setApiKey(''); + // Use empty string - show default as placeholder instead + setBaseUrl(''); + setProviderEnabled(true); + // Select the first preset model by default + setEnabledModels(defaultPresetModels.slice(0, 1).map((m) => m.id)); + setCustomModels([]); + } + setAvailableModels(defaultPresetModels); + setNewModelName(''); + setTestResult(null); + + if (providerType === 'ollama') { + // Pass the URL directly to avoid stale state issue + refreshOllamaModels(configBaseUrl); + } + }; + + const refreshOllamaModels = async (urlOverride?: string) => { + setLoadingModels(true); + try { + // Use urlOverride if provided, otherwise fall back to current state + const url = urlOverride !== undefined ? urlOverride : baseUrl; + const models = await fetchOllamaModels(url || undefined); + if (models.length > 0) { + setAvailableModels(models.map((id) => ({ id, name: id }))); + } + } finally { + setLoadingModels(false); + } + }; + + // Get all selected models (preset + custom) + const getAllEnabledModels = () => [...enabledModels, ...customModels]; + + const handleTest = async () => { + setTesting(true); + setTestResult(null); + try { + const allModels = getAllEnabledModels(); + const config: AIProviderConfig = { + type: providerType, + apiKey, + baseUrl: baseUrl || undefined, + enabledModels: + allModels.length > 0 ? allModels : [meta.defaultModels[0]?.id], + enabled: true, + updatedAt: Date.now(), + }; + const result = await testProviderConnection(config); + setTestResult(result); + } finally { + setTesting(false); + } + }; + + const handleSave = async () => { + setSaving(true); + try { + const allModels = getAllEnabledModels(); + const config: AIProviderConfig = { + type: providerType, + apiKey, + baseUrl: baseUrl || undefined, + enabledModels: allModels, + enabled: providerEnabled, + updatedAt: Date.now(), + }; + await saveProviderConfig(providerType, config); + onClose(); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (window.confirm(`Are you sure you want to remove ${meta.displayName} configuration?`)) { + await deleteProviderConfig(providerType); + onClose(); + } + }; + + const toggleModel = (modelId: string) => { + setEnabledModels((prev) => + prev.includes(modelId) + ? prev.filter((id) => id !== modelId) + : [...prev, modelId] + ); + }; + + const handleAddCustomModel = () => { + const trimmed = newModelName.trim(); + if (!trimmed) return; + // Check if it duplicates a preset model + if (presetModelIds.has(trimmed)) { + return; // Don't allow duplicate of preset + } + // Check if it already exists in custom models + if (customModels.includes(trimmed)) { + return; + } + setCustomModels((prev) => [...prev, trimmed]); + setNewModelName(''); + setShowAddModel(false); + }; + + const getAddModelHelperText = (): string | undefined => { + const trimmed = newModelName.trim(); + if (presetModelIds.has(trimmed)) { + return 'This model is already in presets'; + } + if (customModels.includes(trimmed)) { + return 'This model already exists'; + } + return undefined; + }; + + const handleRemoveCustomModel = (modelId: string) => { + setCustomModels((prev) => prev.filter((id) => id !== modelId)); + }; + + const handleStartEditCustomModel = (index: number, modelId: string) => { + setEditingModelIndex(index); + setEditingModelName(modelId); + }; + + const handleSaveEditCustomModel = () => { + if (editingModelIndex === null) return; + const trimmed = editingModelName.trim(); + if (!trimmed) return; + // Check if it duplicates a preset model + if (presetModelIds.has(trimmed)) return; + // Check if it duplicates another custom model (excluding current) + const otherCustomModels = customModels.filter((_, i) => i !== editingModelIndex); + if (otherCustomModels.includes(trimmed)) return; + + setCustomModels((prev) => { + const newModels = [...prev]; + newModels[editingModelIndex] = trimmed; + return newModels; + }); + setEditingModelIndex(null); + setEditingModelName(''); + }; + + const handleCancelEditCustomModel = () => { + setEditingModelIndex(null); + setEditingModelName(''); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddCustomModel(); + } + }; + + const handleEditKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSaveEditCustomModel(); + } else if (e.key === 'Escape') { + handleCancelEditCustomModel(); + } + }; + + const allSelectedCount = enabledModels.length + customModels.length; + // Azure providers require baseUrl to be set + const isAzureProvider = providerType === 'azure-openai' || providerType === 'azure-ai'; + const hasRequiredBaseUrl = !isAzureProvider || baseUrl.trim() !== ''; + const canSave = + (providerType === 'ollama' || apiKey.trim() !== '') && + allSelectedCount > 0 && + hasRequiredBaseUrl; + + const getApiUrlHelperText = (type: ProviderType): string => { + if (type === 'ollama') { + return 'Default: http://localhost:11434'; + } + if (type === 'azure-openai' || type === 'azure-ai') { + return 'Required: Your Azure endpoint URL'; + } + return 'Leave empty to use the default endpoint'; + }; + + return ( + + + {meta.displayName} Settings + + + + + + + + {meta.requiresApiKey && ( + setApiKey(e.target.value)} + fullWidth + size="small" + placeholder={`Enter your ${meta.displayName} API key`} + InputProps={{ + endAdornment: ( + + setShowApiKey(!showApiKey)} + edge="end" + size="small" + > + {showApiKey ? : } + + + ), + }} + /> + )} + + {meta.supportsCustomUrl && ( + setBaseUrl(e.target.value)} + fullWidth + size="small" + placeholder={meta.defaultBaseUrl || 'Custom API endpoint'} + helperText={getApiUrlHelperText(providerType)} + /> + )} + + + + {providerType === 'ollama' && ( + + )} + + + {testResult && ( + + {testResult.message} + + )} + + + + + Models + + ({allSelectedCount} models available) + + + setShowAddModel(!showAddModel)} + color={showAddModel ? 'primary' : 'default'} + title="Add custom model" + > + + + + + {/* Add custom model input */} + {showAddModel && ( + + setNewModelName(e.target.value)} + onKeyDown={handleKeyDown} + sx={{ flex: 1 }} + error={!!getAddModelHelperText()} + helperText={getAddModelHelperText()} + autoFocus + /> + + + )} + + + {/* Custom models first */} + {customModels.map((modelId, index) => ( + handleRemoveCustomModel(modelId)} + /> + } + > + {editingModelIndex === index ? ( + setEditingModelName(e.target.value)} + onKeyDown={handleEditKeyDown} + onBlur={handleSaveEditCustomModel} + autoFocus + sx={{ flex: 1, mr: 1 }} + /> + ) : ( + + + handleStartEditCustomModel(index, modelId)} + title="Edit model name" + > + + + handleRemoveCustomModel(modelId)} + title="Delete model" + > + + + + )} + + ))} + {/* Preset models */} + {availableModels.map((model) => ( + toggleModel(model.id)} + /> + } + > + + + ))} + + {allSelectedCount === 0 && ( + + Please enable at least one model + + )} + + + + + + + + setProviderEnabled(e.target.checked)} + size="small" + /> + } + label="Enable" + labelPlacement="start" + sx={{ + mr: 1, + '& .MuiFormControlLabel-label': { + color: 'text.secondary', + fontSize: '0.875rem', + }, + }} + /> + + + + + ); +}; diff --git a/app/extension/src/components/AIProvidersSettings.tsx b/app/extension/src/components/AIProvidersSettings.tsx new file mode 100644 index 00000000..b156ce34 --- /dev/null +++ b/app/extension/src/components/AIProvidersSettings.tsx @@ -0,0 +1,234 @@ +import React, { useEffect, useState } from 'react'; +import { + Box, + Button, + Chip, + Paper, + Typography, +} from '@mui/material'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import { + ProviderType, + PROVIDER_ORDER, + PROVIDER_REGISTRY, + AIProviderConfig, + DEFAULT_AI_STORAGE, +} from '../ai/types'; +import { + getAIProvidersStorage, +} from '../ai/storage'; +import { AIProviderDialog } from './AIProviderDialog'; + +const PROVIDER_ICONS: Record = { + openai: ( + + + + ), + deepseek: DS, + groq: G, + google: ( + + + + + + + ), + ollama: O, + anthropic: ( + + + + ), + 'azure-openai': ( + + + + ), + 'azure-ai': ( + + + + ), + zhipu: ( + 智谱 + ), + minimax: ( + MM + ), + 'huntly-server': ( + + + + ), +}; + +export const AIProvidersSettings: React.FC = () => { + const [providers, setProviders] = useState< + Record + >(DEFAULT_AI_STORAGE.providers); + const [dialogOpen, setDialogOpen] = useState(false); + const [selectedProvider, setSelectedProvider] = useState( + null + ); + + const loadProviders = async () => { + const storage = await getAIProvidersStorage(); + setProviders(storage.providers); + }; + + useEffect(() => { + loadProviders(); + }, []); + + const handleOpenDialog = (type: ProviderType) => { + setSelectedProvider(type); + setDialogOpen(true); + }; + + const handleCloseDialog = () => { + setDialogOpen(false); + setSelectedProvider(null); + loadProviders(); + }; + + // Filter out huntly-server from the visible providers + const visibleProviders = PROVIDER_ORDER.filter((type) => type !== 'huntly-server'); + + return ( +
+
+

AI Service Providers

+

+ API keys are stored locally and never sent to Huntly servers. +

+
+ + + {visibleProviders.map((type) => { + const meta = PROVIDER_REGISTRY[type]; + const config = providers[type]; + const hasConfig = config !== null; + const isEnabled = config?.enabled ?? false; + + return ( + + {hasConfig && ( + + )} + + + + {PROVIDER_ICONS[type]} + + + + {meta.displayName} + + + {meta.description} + + + + + + {hasConfig && config ? ( + 1 ? 's' : ''}`} + size="small" + color={isEnabled ? 'primary' : 'default'} + variant="outlined" + /> + ) : ( + + )} + + + + ); + })} + + + {selectedProvider && ( + + )} +
+ ); +}; diff --git a/app/extension/src/components/AIToolbar.tsx b/app/extension/src/components/AIToolbar.tsx new file mode 100644 index 00000000..fae56f40 --- /dev/null +++ b/app/extension/src/components/AIToolbar.tsx @@ -0,0 +1,592 @@ +import React, { useState, useEffect } from 'react'; +import { + Button, + Menu, + MenuItem, + ListSubheader, + Divider, + CircularProgress, + Typography, + Box, + IconButton, +} from '@mui/material'; +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; +import SettingsIcon from '@mui/icons-material/Settings'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import StopRoundedIcon from '@mui/icons-material/StopRounded'; +import { + Prompt, + getPromptsSettings, + saveSelectedModelId, + getSelectedModelId, +} from '../storage'; +import { fetchEnabledShortcuts, getApiBaseUrl } from '../services'; +import { + getAIProvidersStorage, + getAvailableProviderTypes, + getEffectiveDefaultProviderType, +} from '../ai/storage'; +import { + ProviderType, + PROVIDER_REGISTRY, +} from '../ai/types'; + +// Types +export interface ShortcutItem { + id: string | number; + name: string; + content?: string; + type: 'user' | 'system' | 'huntly'; +} + +export interface ModelItem { + id: string; + name: string; + provider: ProviderType; + providerName: string; +} + +/** Data structure for externally provided shortcuts */ +export interface ExternalShortcutsData { + userPrompts: Prompt[]; + systemPrompts: Prompt[]; + huntlyShortcuts: any[]; + huntlyShortcutsEnabled: boolean; +} + +/** Data structure for externally provided models */ +export interface ExternalModelsData { + models: ModelItem[]; + defaultModel: ModelItem | null; +} + +export interface AIToolbarProps { + onShortcutClick: (shortcut: ShortcutItem, selectedModel: ModelItem | null) => void; + isProcessing?: boolean; + showPreview?: boolean; + onPreviewClick?: () => void; + /** Called when user clicks the stop button during processing */ + onStopClick?: () => void; + compact?: boolean; + /** Container element for Menu portals (used in Shadow DOM) */ + menuContainer?: HTMLElement | (() => HTMLElement); + /** Externally provided shortcuts data (for content script use) */ + externalShortcuts?: ExternalShortcutsData; + /** Externally provided models data (for content script use) */ + externalModels?: ExternalModelsData; + /** Initial selected model (used when auto-executing from popup) */ + initialSelectedModel?: ModelItem | null; +} + +// Gradient definition for AI icon +export const AIGradientDef = () => ( + + + + + + + +); + +export const AIToolbar: React.FC = ({ + onShortcutClick, + isProcessing = false, + onStopClick, + compact = false, + menuContainer, + externalShortcuts, + externalModels, + initialSelectedModel, +}) => { + // Determine if using external data + const useExternalShortcuts = !!externalShortcuts; + const useExternalModels = !!externalModels; + + // Shortcuts state + const [userPrompts, setUserPrompts] = useState( + externalShortcuts?.userPrompts || [] + ); + const [systemPrompts, setSystemPrompts] = useState( + externalShortcuts?.systemPrompts || [] + ); + const [huntlyShortcuts, setHuntlyShortcuts] = useState( + externalShortcuts?.huntlyShortcuts || [] + ); + const [huntlyShortcutsEnabled, setHuntlyShortcutsEnabled] = useState( + externalShortcuts?.huntlyShortcutsEnabled ?? true + ); + const [loadingShortcuts, setLoadingShortcuts] = useState(!useExternalShortcuts); + + // Models state + const [models, setModels] = useState( + externalModels?.models || [] + ); + const [selectedModel, setSelectedModel] = useState( + initialSelectedModel || null + ); + const [loadingModels, setLoadingModels] = useState(!useExternalModels); + + // Restore saved model when using external models (and no initialSelectedModel provided) + useEffect(() => { + if (useExternalModels && externalModels && !initialSelectedModel) { + // Try to restore previously saved model + getSelectedModelId().then((savedModelId) => { + let model: ModelItem | null = null; + if (savedModelId) { + model = externalModels.models.find(m => m.id === savedModelId) || null; + } + // Fall back to default model if saved one not found + if (!model) { + model = externalModels.defaultModel; + } + setSelectedModel(model); + }); + } + }, [useExternalModels, externalModels, initialSelectedModel]); + + // Sync selectedModel when initialSelectedModel is provided (for auto-execute from popup) + useEffect(() => { + if (initialSelectedModel) { + setSelectedModel(initialSelectedModel); + } + }, [initialSelectedModel]); + + // Menu state + const [shortcutAnchorEl, setShortcutAnchorEl] = useState(null); + const [modelAnchorEl, setModelAnchorEl] = useState(null); + const shortcutMenuOpen = Boolean(shortcutAnchorEl); + const modelMenuOpen = Boolean(modelAnchorEl); + + // Load shortcuts (only if not using external data) + useEffect(() => { + if (useExternalShortcuts) return; + + async function loadShortcuts() { + setLoadingShortcuts(true); + try { + // Load prompts from storage + const promptsSettings = await getPromptsSettings(); + const enabledPrompts = promptsSettings.prompts.filter(p => p.enabled); + setUserPrompts(enabledPrompts.filter(p => !p.isSystem)); + setSystemPrompts(enabledPrompts.filter(p => p.isSystem)); + setHuntlyShortcutsEnabled(promptsSettings.huntlyShortcutsEnabled); + + // Check if server is configured + const baseUrl = await getApiBaseUrl(); + + // Load huntly shortcuts if enabled and server configured + if (baseUrl && promptsSettings.huntlyShortcutsEnabled) { + try { + // Try direct fetch first (works in popup/options), + // fall back to message passing for content scripts (CORS issues) + let shortcuts: any[] = []; + try { + shortcuts = await fetchEnabledShortcuts(); + } catch (fetchError) { + // Direct fetch failed (likely CORS in content script), use message passing + const response = await new Promise<{ success: boolean; shortcuts: any[] }>((resolve) => { + chrome.runtime.sendMessage( + { type: 'get_huntly_shortcuts' }, + (resp) => { + if (chrome.runtime.lastError) { + console.error('Message passing failed:', chrome.runtime.lastError); + resolve({ success: false, shortcuts: [] }); + } else { + resolve(resp || { success: false, shortcuts: [] }); + } + } + ); + }); + shortcuts = response.shortcuts || []; + } + setHuntlyShortcuts(shortcuts); + } catch (error) { + console.error('Failed to load huntly shortcuts:', error); + setHuntlyShortcuts([]); + } + } + } catch (error) { + console.error('Failed to load shortcuts:', error); + } finally { + setLoadingShortcuts(false); + } + } + loadShortcuts(); + }, [useExternalShortcuts]); + + // Load models (only if not using external data) + useEffect(() => { + if (useExternalModels) return; + + async function loadModels() { + setLoadingModels(true); + try { + const modelList: ModelItem[] = []; + const storage = await getAIProvidersStorage(); + const availableProviders = await getAvailableProviderTypes(); + const baseUrl = await getApiBaseUrl(); + const promptsSettings = await getPromptsSettings(); + + // Add Huntly Server models first (only if huntlyShortcutsEnabled) + if (baseUrl && promptsSettings.huntlyShortcutsEnabled) { + modelList.push({ + id: 'huntly-server:default', + name: 'Huntly AI', + provider: 'huntly-server', + providerName: 'Huntly', + }); + } + + // Add models from enabled providers + for (const providerType of availableProviders) { + if (providerType === 'huntly-server') continue; + + const config = storage.providers[providerType]; + if (config?.enabled && config.enabledModels.length > 0) { + const providerMeta = PROVIDER_REGISTRY[providerType]; + for (const modelId of config.enabledModels) { + const modelMeta = providerMeta.defaultModels.find(m => m.id === modelId); + modelList.push({ + id: `${providerType}:${modelId}`, + name: modelMeta?.name || modelId, + provider: providerType, + providerName: providerMeta.displayName, + }); + } + } + } + + setModels(modelList); + + // Set default selected model + if (modelList.length > 0) { + // Try to restore previously selected model + const savedModelId = await getSelectedModelId(); + let selectedModel: ModelItem | null = null; + + if (savedModelId) { + selectedModel = modelList.find(m => m.id === savedModelId) || null; + } + + // Fall back to default provider or first model + if (!selectedModel) { + const defaultProviderType = await getEffectiveDefaultProviderType(); + if (defaultProviderType) { + selectedModel = modelList.find(m => m.provider === defaultProviderType) || modelList[0]; + } else { + selectedModel = modelList[0]; + } + } + + setSelectedModel(selectedModel); + } + } catch (error) { + console.error('Failed to load models:', error); + } finally { + setLoadingModels(false); + } + } + loadModels(); + }, [useExternalModels]); + + const handleShortcutMenuOpen = (event: React.MouseEvent) => { + setShortcutAnchorEl(event.currentTarget); + }; + + const handleShortcutMenuClose = () => { + setShortcutAnchorEl(null); + }; + + const handleModelMenuOpen = (event: React.MouseEvent) => { + setModelAnchorEl(event.currentTarget); + }; + + const handleModelMenuClose = () => { + setModelAnchorEl(null); + }; + + const handleShortcutSelect = (shortcut: ShortcutItem) => { + handleShortcutMenuClose(); + onShortcutClick(shortcut, selectedModel); + }; + + const handleModelSelect = (model: ModelItem) => { + setSelectedModel(model); + // Save selection for next time + saveSelectedModelId(model.id); + handleModelMenuClose(); + }; + + const openSettings = (tab?: string) => { + const optionsUrl = chrome.runtime.getURL('options.html'); + const url = tab ? `${optionsUrl}#${tab}` : optionsUrl; + // chrome.tabs.create is not available in content scripts, use message passing + if (chrome.tabs?.create) { + chrome.tabs.create({ url }); + } else { + // Fallback for content script context - send message to background + chrome.runtime.sendMessage({ type: 'open_tab', url }); + } + handleShortcutMenuClose(); + handleModelMenuClose(); + }; + + // Check if selected model is Huntly AI + const isHuntlyAISelected = selectedModel?.provider === 'huntly-server'; + + // Determine which shortcuts to show based on selected model + const showHuntlyShortcuts = isHuntlyAISelected && huntlyShortcutsEnabled && huntlyShortcuts.length > 0; + const showUserSystemPrompts = !isHuntlyAISelected; + + const hasShortcuts = showHuntlyShortcuts || + (showUserSystemPrompts && (userPrompts.length > 0 || systemPrompts.length > 0)); + const hasModels = models.length > 0; + + // Group models by provider - only show Huntly AI when huntlyShortcutsEnabled is true + const huntlyModels = huntlyShortcutsEnabled + ? models.filter(m => m.provider === 'huntly-server') + : []; + const otherModels = models.filter(m => m.provider !== 'huntly-server'); + + // Group other models by provider + const modelsByProvider = otherModels.reduce((acc, model) => { + if (!acc[model.providerName]) { + acc[model.providerName] = []; + } + acc[model.providerName].push(model); + return acc; + }, {} as Record); + + const buttonSize = compact ? 'small' : 'medium'; + // Ensure portal-based menus render above the shadow DOM overlay. + const menuZIndex = 2147483647; + + return ( + + + + {/* Model Selector */} + + + + {!hasModels ? ( + openSettings('ai-providers')}> + + Configure AI Providers + + ) : ( + <> + {/* Huntly models */} + {huntlyModels.map(model => ( + handleModelSelect(model)} + selected={selectedModel?.id === model.id} + > + {model.name} + + ))} + {/* Other providers */} + {Object.entries(modelsByProvider).map(([providerName, providerModels], index) => ( + + {(index > 0 || huntlyModels.length > 0) && } + + {providerName} + + {providerModels.map(model => ( + handleModelSelect(model)} + selected={selectedModel?.id === model.id} + > + {model.name} + + ))} + + ))} + + )} + + + {/* AI Shortcuts Button */} + + + {/* Stop Button - Circular with pulse animation */} + {isProcessing && onStopClick && ( + + + + )} + + + {loadingShortcuts ? ( + + + Loading... + + ) : !hasShortcuts ? ( + openSettings('prompts')}> + + {isHuntlyAISelected ? 'Configure Huntly Shortcuts' : 'Configure Prompts'} + + ) : ( + <> + {/* Huntly AI selected - show only Huntly Shortcuts */} + {showHuntlyShortcuts && ( + <> + + Huntly Shortcuts + + {huntlyShortcuts.map(shortcut => ( + handleShortcutSelect({ + id: shortcut.id, + name: shortcut.name, + content: shortcut.prompt, + type: 'huntly', + })} + > + {shortcut.name} + + ))} + + )} + {/* Other models selected - show User & System Prompts */} + {showUserSystemPrompts && ( + <> + {/* User Prompts */} + {userPrompts.length > 0 && ( + + Prompts + + )} + {userPrompts.map(prompt => ( + handleShortcutSelect({ + id: prompt.id, + name: prompt.name, + content: prompt.content, + type: 'user', + })} + > + {prompt.name} + + ))} + {/* System Prompts */} + {systemPrompts.length > 0 && userPrompts.length > 0 && } + {systemPrompts.length > 0 && ( + + System Prompts + + )} + {systemPrompts.map(prompt => ( + handleShortcutSelect({ + id: prompt.id, + name: prompt.name, + content: prompt.content, + type: 'system', + })} + > + {prompt.name} + + ))} + + )} + + )} + + + ); +}; + +export default AIToolbar; diff --git a/app/extension/src/components/ArticlePreview.tsx b/app/extension/src/components/ArticlePreview.tsx new file mode 100644 index 00000000..d8a8ac1e --- /dev/null +++ b/app/extension/src/components/ArticlePreview.tsx @@ -0,0 +1,331 @@ +import React, { useState, useEffect, useCallback, useRef } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import TurndownService from "turndown"; +import { ContentParserType } from "../storage"; +import { parseDocument } from "../parser/contentParser"; +import AIToolbar, { ShortcutItem, ModelItem, AIGradientDef, ExternalShortcutsData, ExternalModelsData } from "./AIToolbar"; +import { useShadowContainer } from "./ShadowDomPreview"; + +// Create turndown instance for HTML to markdown conversion +const turndownService = new TurndownService({ + headingStyle: 'atx', + codeBlockStyle: 'fenced', +}); + +// Helper function to convert HTML to markdown +function htmlToMarkdown(html: string): string { + if (!html) return ''; + try { + return turndownService.turndown(html); + } catch (error) { + console.error('Failed to convert HTML to markdown:', error); + return html; // Fallback to original HTML if conversion fails + } +} + +interface ArticlePreviewProps { + page: PageModel; + initialParserType: ContentParserType; + onClose: () => void; + onParserChange?: (parserType: ContentParserType, newPage: PageModel) => void; + /** Externally provided shortcuts data (for content script use) */ + externalShortcuts?: ExternalShortcutsData; + /** Externally provided models data (for content script use) */ + externalModels?: ExternalModelsData; + /** Shortcut to auto-execute on mount */ + autoExecuteShortcut?: ShortcutItem; + /** Model to use for auto-execute */ + autoSelectedModel?: ModelItem | null; +} + +// Streaming content renderer component +const StreamingContentRenderer = ({ currentTaskId }: { currentTaskId: string | null }) => { + const [processedContent, setProcessedContent] = useState(""); + const contentRef = useRef(null); + + useEffect(() => { + setProcessedContent(""); + const messageListener = (msg: any) => { + if (!currentTaskId || msg.payload?.taskId !== currentTaskId) return; + if (msg.type === "shortcuts_process_data") { + setProcessedContent(msg.payload.accumulatedContent); + } + }; + chrome.runtime.onMessage.addListener(messageListener); + return () => chrome.runtime.onMessage.removeListener(messageListener); + }, [currentTaskId]); + + if (!processedContent) { + return ( +
+ {[100, 80, 60].map((width) => ( +
+ ))} +
+ ); + } + + return ( +
+ {processedContent} +
+ ); +}; + +// Close icon SVG +const CloseIcon = () => ( + + + +); + +// Helper function to check if URL is from X/Twitter +function isXTwitterSite(url: string | undefined, domain: string | undefined): boolean { + if (domain === "twitter.com" || domain === "x.com") { + return true; + } + if (url) { + try { + const urlObj = new URL(url); + const hostname = urlObj.hostname; + return hostname === "twitter.com" || hostname === "x.com"; + } catch { + return false; + } + } + return false; +} + +export const ArticlePreview: React.FC = ({ + page: initialPage, + initialParserType, + onClose, + onParserChange, + externalShortcuts, + externalModels, + autoExecuteShortcut, + autoSelectedModel, +}) => { + const [page, setPage] = useState(initialPage); + const [parserType, setParserType] = useState(initialParserType); + const [isProcessing, setIsProcessing] = useState(false); + const [showProcessedSection, setShowProcessedSection] = useState(false); + const [currentTaskId, setCurrentTaskId] = useState(null); + const [processingError, setProcessingError] = useState(null); + + // Get shadow container for MUI Menu components + const shadowContainer = useShadowContainer(); + + const isSnippetMode = page.contentType === 4; + const isXSite = isXTwitterSite(page.url, page.domain); + + // Handle parser change + const handleParserChange = useCallback((e: React.ChangeEvent) => { + const newParserType = e.target.value as ContentParserType; + setParserType(newParserType); + + // Re-parse the document with the new parser + const doc = document.cloneNode(true) as Document; + const article = parseDocument(doc, newParserType); + + if (article) { + const newPage: PageModel = { + ...page, + title: article.title || page.title, + content: article.content, + description: article.excerpt || page.description, + author: article.byline || page.author, + siteName: article.siteName || page.siteName, + }; + setPage(newPage); + onParserChange?.(newParserType, newPage); + } + }, [page, onParserChange]); + + // Handle shortcut click from AIToolbar + const handleShortcutClick = useCallback((shortcut: ShortcutItem, selectedModel: ModelItem | null) => { + if (isProcessing && currentTaskId) { + chrome.runtime.sendMessage({ + type: "shortcuts_cancel", + payload: { taskId: currentTaskId }, + }); + } + + setProcessingError(null); + setIsProcessing(true); + + const newTaskId = `task_${Date.now()}_${Math.random().toString(36).substring(7)}`; + setCurrentTaskId(newTaskId); + setShowProcessedSection(true); + + // Get HTML content to process + const htmlContent = isSnippetMode ? (page.description || page.content) : page.content; + // Convert HTML to markdown for AI processing + const markdownContent = htmlToMarkdown(htmlContent); + + chrome.runtime.sendMessage({ + type: "shortcuts_process", + payload: { + tabId: null, + taskId: newTaskId, + shortcutId: shortcut.id, + shortcutName: shortcut.name, + shortcutContent: shortcut.content, + shortcutType: shortcut.type, + content: markdownContent, // Send markdown instead of HTML + url: page.url, + title: isSnippetMode ? "" : page.title, + contentType: isSnippetMode ? 4 : undefined, + selectedModel: selectedModel, + }, + }); + }, [isProcessing, currentTaskId, page, isSnippetMode]); + + // Handle stop button click + const handleStopClick = useCallback(() => { + if (currentTaskId) { + chrome.runtime.sendMessage({ + type: "shortcuts_cancel", + payload: { taskId: currentTaskId }, + }); + setIsProcessing(false); + } + }, [currentTaskId]); + + // Message listener for processing events + useEffect(() => { + const messageListener = (msg: any) => { + if (!currentTaskId || msg.payload?.taskId !== currentTaskId) return; + + switch (msg.type) { + case "shortcuts_processing_start": + setIsProcessing(true); + break; + case "shortcuts_process_result": + setIsProcessing(false); + break; + case "shortcuts_process_error": + setIsProcessing(false); + setProcessingError(msg.payload?.error || "Processing failed"); + break; + } + }; + chrome.runtime.onMessage.addListener(messageListener); + return () => chrome.runtime.onMessage.removeListener(messageListener); + }, [currentTaskId]); + + // Handle escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + // Auto-execute shortcut if provided (from popup) + const autoExecuteRef = useRef(false); + useEffect(() => { + if (autoExecuteShortcut && !autoExecuteRef.current) { + autoExecuteRef.current = true; + // Small delay to ensure component is fully mounted + setTimeout(() => { + handleShortcutClick(autoExecuteShortcut, autoSelectedModel || null); + }, 100); + } + }, [autoExecuteShortcut, autoSelectedModel, handleShortcutClick]); + + return ( +
e.target === e.currentTarget && onClose()} + role="dialog" + aria-label="Article Preview" + > + +
+
+ {/* Header bar */} +
+ {/* AI Toolbar with Model Selector and Shortcuts */} + + + {/* Right section: Parser selector and Close button */} +
+ {/* Parser selector */} +
+ Parser: + +
+ + {/* Close button */} + +
+
+ + {/* Content area */} +
+ {/* Article section */} +
+
+
+ {!isSnippetMode && !isXSite &&

{page.title}

} +
+
+
+
+ + {/* Processed section */} + {showProcessedSection && ( +
+
+ {processingError ? ( +
+ Error: {processingError} +
+ ) : ( + + )} +
+
+ )} +
+
+
+
+ ); +}; + +export default ArticlePreview; + diff --git a/app/extension/src/components/AutoSaveSettings.tsx b/app/extension/src/components/AutoSaveSettings.tsx new file mode 100644 index 00000000..37101fd1 --- /dev/null +++ b/app/extension/src/components/AutoSaveSettings.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useState } from 'react'; +import { + Alert, + Button, + FormControlLabel, + Snackbar, + Switch, +} from '@mui/material'; +import { ContentParserType, readSyncStorageSettings, StorageSettings, DefaultStorageSettings } from '../storage'; + +export type AutoSaveSettingsProps = { + onSettingsChange?: (settings: StorageSettings) => void; +}; + +export const AutoSaveSettings: React.FC = ({ + onSettingsChange, +}) => { + const [autoSaveEnabled, setAutoSaveEnabled] = useState(true); + const [autoSaveTweet, setAutoSaveTweet] = useState(false); + const [showSavedTip, setShowSavedTip] = useState(false); + const [serverUrl, setServerUrl] = useState(''); + const [serverUrlList, setServerUrlList] = useState<{ url: string }[]>([]); + const [contentParser, setContentParser] = useState('readability'); + + useEffect(() => { + readSyncStorageSettings().then((settings) => { + setAutoSaveEnabled(settings.autoSaveEnabled); + setAutoSaveTweet(settings.autoSaveTweet); + setServerUrl(settings.serverUrl); + setServerUrlList(settings.serverUrlList); + setContentParser(settings.contentParser); + }); + }, []); + + const handleSave = () => { + const storageSettings: StorageSettings = { + ...DefaultStorageSettings, + serverUrl, + serverUrlList, + autoSaveEnabled, + autoSaveTweet, + contentParser, + }; + chrome.storage.sync.set( + { + autoSaveEnabled, + autoSaveTweet, + }, + () => { + setShowSavedTip(true); + if (onSettingsChange) { + onSettingsChange(storageSettings); + } + } + ); + }; + + return ( +
+ setShowSavedTip(false)} + > + setShowSavedTip(false)}> + Settings saved. + + + +
+

Auto Save Article

+

+ Automatically save articles when you browse web pages. +

+
+ +
+ setAutoSaveEnabled(e.target.checked)} + /> + } + label="Enable auto save articles" + /> +
+ +
+

Auto Save Tweet

+

+ Automatically save tweets when you browse Twitter/X. +

+
+ +
+ setAutoSaveTweet(e.target.checked)} + /> + } + label="Enable auto save tweets" + /> +
+ + +
+ ); +}; diff --git a/app/extension/src/components/ParserSettings.tsx b/app/extension/src/components/ParserSettings.tsx new file mode 100644 index 00000000..bf3d7af7 --- /dev/null +++ b/app/extension/src/components/ParserSettings.tsx @@ -0,0 +1,109 @@ +import React, { useEffect, useState } from 'react'; +import { + Alert, + FormControl, + FormControlLabel, + Radio, + RadioGroup, + Snackbar, + Typography, + Box, + Link, +} from '@mui/material'; +import { + ContentParserType, + readSyncStorageSettings, + STORAGE_CONTENT_PARSER, +} from '../storage'; + +export const ParserSettings: React.FC = () => { + const [contentParser, setContentParser] = useState('readability'); + const [showSavedTip, setShowSavedTip] = useState(false); + + useEffect(() => { + readSyncStorageSettings().then((settings) => { + setContentParser(settings.contentParser); + }); + }, []); + + const handleParserChange = (value: ContentParserType) => { + setContentParser(value); + chrome.storage.sync.set( + { + [STORAGE_CONTENT_PARSER]: value, + }, + () => { + setShowSavedTip(true); + } + ); + }; + + return ( +
+ setShowSavedTip(false)} + > + setShowSavedTip(false)}> + Settings saved. + + + +
+

Default Content Parser

+

+ Choose the parser engine for extracting article content. +

+
+ + + handleParserChange(e.target.value as ContentParserType)} + > + } + label={ + + + Mozilla Readability + + + The classic parser used by Firefox Reader View. Stable and well-tested. + + + } + sx={{ alignItems: 'flex-start', mb: 2 }} + /> + } + label={ + + + Defuddle + + + A modern parser by Obsidian. More forgiving, extracts more metadata, + and provides consistent output for code blocks, footnotes, and math.{' '} + + Learn more + + + + } + sx={{ alignItems: 'flex-start' }} + /> + + +
+ ); +}; + diff --git a/app/extension/src/components/PromptsSettings.tsx b/app/extension/src/components/PromptsSettings.tsx new file mode 100644 index 00000000..2d3d1ac2 --- /dev/null +++ b/app/extension/src/components/PromptsSettings.tsx @@ -0,0 +1,547 @@ +import React, { useEffect, useState } from 'react'; +import { + Alert, + Box, + Button, + TextField, + Typography, + IconButton, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + List, + ListItem, + ListItemText, + ListItemSecondaryAction, + Paper, + Divider, + Snackbar, + Chip, + Switch, + Autocomplete, + Tooltip, +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import LockIcon from '@mui/icons-material/Lock'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import { + Prompt, + getPromptsSettings, + savePromptsSettings, + LANGUAGES, + LanguageOption, + getBrowserLanguage, + findLanguageByEnglish, +} from '../storage'; + +interface PromptDialogProps { + open: boolean; + prompt: Prompt | null; + onClose: () => void; + onSave: (name: string, content: string) => void; +} + +interface ViewPromptDialogProps { + open: boolean; + prompt: Prompt | null; + onClose: () => void; +} + +const ViewPromptDialog: React.FC = ({ + open, + prompt, + onClose, +}) => { + if (!prompt) return null; + + return ( + + {prompt.name} + + + {prompt.content} + + + + + + + ); +}; + +const PromptDialog: React.FC = ({ + open, + prompt, + onClose, + onSave, +}) => { + const [name, setName] = useState(''); + const [content, setContent] = useState(''); + + useEffect(() => { + if (prompt) { + setName(prompt.name); + setContent(prompt.content); + } else { + setName(''); + setContent(''); + } + }, [prompt, open]); + + const handleSave = () => { + if (name.trim() && content.trim()) { + onSave(name.trim(), content.trim()); + } + }; + + return ( + + {prompt ? 'Edit Prompt' : 'Add Prompt'} + + setName(e.target.value)} + sx={{ mb: 2 }} + /> + setContent(e.target.value)} + helperText="Use {lang} as a placeholder for the output language" + /> + + + + + + + ); +}; + +export const PromptsSettings: React.FC = () => { + const [defaultTargetLanguage, setDefaultTargetLanguage] = useState(''); + const [savedLanguage, setSavedLanguage] = useState(''); // Track last saved language + const [prompts, setPrompts] = useState([]); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingPrompt, setEditingPrompt] = useState(null); + const [viewDialogOpen, setViewDialogOpen] = useState(false); + const [viewingPrompt, setViewingPrompt] = useState(null); + const [snackbarOpen, setSnackbarOpen] = useState(false); + const [snackbarMessage, setSnackbarMessage] = useState(''); + const [isInitialized, setIsInitialized] = useState(false); + + const loadSettings = async () => { + const settings = await getPromptsSettings(); + // Use browser language as default if not set or empty + const language = settings.defaultTargetLanguage || getBrowserLanguage(); + setDefaultTargetLanguage(language); + setSavedLanguage(language); + setPrompts(settings.prompts); + setIsInitialized(true); + }; + + useEffect(() => { + loadSettings(); + }, []); + + const saveSettings = async ( + language: string, + promptsList: Prompt[] + ) => { + const settings = await getPromptsSettings(); + await savePromptsSettings({ + defaultTargetLanguage: language, + prompts: promptsList, + huntlyShortcutsEnabled: settings.huntlyShortcutsEnabled, + }); + setSnackbarMessage('Settings saved'); + setSnackbarOpen(true); + }; + + const handleDefaultLanguageChange = async (language: string, force = false) => { + if (!language) return; + // Skip if language hasn't changed from last saved value (unless forced) + if (!force && language === savedLanguage) return; + + setDefaultTargetLanguage(language); + setSavedLanguage(language); + await saveSettings(language, prompts); + // Reload settings to get localized system prompts + const settings = await getPromptsSettings(); + setPrompts(settings.prompts); + }; + + const handlePromptToggle = async (promptId: string, enabled: boolean) => { + const updatedPrompts = prompts.map((p) => + p.id === promptId ? { ...p, enabled, updatedAt: Date.now() } : p + ); + setPrompts(updatedPrompts); + await saveSettings(defaultTargetLanguage, updatedPrompts); + }; + + const handleAddPrompt = () => { + setEditingPrompt(null); + setDialogOpen(true); + }; + + const handleEditPrompt = (prompt: Prompt) => { + setEditingPrompt(prompt); + setDialogOpen(true); + }; + + const handleViewPrompt = (prompt: Prompt) => { + setViewingPrompt(prompt); + setViewDialogOpen(true); + }; + + const handleDeletePrompt = async (promptId: string) => { + const updatedPrompts = prompts.filter((p) => p.id !== promptId); + setPrompts(updatedPrompts); + await saveSettings(defaultTargetLanguage, updatedPrompts); + }; + + const handleSavePrompt = async (name: string, content: string) => { + let updatedPrompts: Prompt[]; + + if (editingPrompt) { + updatedPrompts = prompts.map((p) => + p.id === editingPrompt.id + ? { + ...p, + name, + content, + updatedAt: Date.now() + } + : p + ); + } else { + const newPrompt: Prompt = { + id: `user_prompt_${Date.now()}`, + name, + content, + targetLanguage: defaultTargetLanguage, + enabled: true, + isSystem: false, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + updatedPrompts = [...prompts, newPrompt]; + } + + setPrompts(updatedPrompts); + await saveSettings(defaultTargetLanguage, updatedPrompts); + setDialogOpen(false); + setEditingPrompt(null); + }; + + const systemPrompts = prompts.filter((p) => p.isSystem); + const userPrompts = prompts.filter((p) => !p.isSystem); + + return ( +
+
+

Prompts

+

Configure prompts for AI processing.

+
+ + {/* Default Output Language */} + + { + if (typeof option === 'string') return option; + return option.english; + }} + value={findLanguageByEnglish(defaultTargetLanguage) || null} + inputValue={defaultTargetLanguage} + onInputChange={(_, newValue) => { + if (newValue !== defaultTargetLanguage) { + setDefaultTargetLanguage(newValue); + } + }} + onChange={(_, newValue) => { + if (newValue) { + const language = typeof newValue === 'string' ? newValue : newValue.english; + handleDefaultLanguageChange(language); + } + }} + onBlur={() => { + // Save on blur for manual input (compare with last saved value) + if (defaultTargetLanguage && isInitialized && defaultTargetLanguage !== savedLanguage) { + handleDefaultLanguageChange(defaultTargetLanguage, true); + } + }} + renderOption={(props, option) => ( + + + {option.english} + + + {option.native} + + + )} + renderInput={(params) => ( + + )} + sx={{ width: '100%' }} + /> + + + {/* User Prompts */} + + + + Prompts + + + + + {userPrompts.length === 0 ? ( + + No prompts yet + + Click "Add" to create your own prompt + + + ) : ( + + + {userPrompts.map((prompt, index) => ( + + {index > 0 && } + + handlePromptToggle(prompt.id, e.target.checked)} + size="small" + sx={{ mr: 1 }} + /> + + {prompt.content} + + } + placement="top-start" + arrow + enterDelay={500} + slotProps={{ + tooltip: { + sx: { maxWidth: 500, p: 1.5 } + } + }} + > + + {prompt.content} + + } + /> + + + handleEditPrompt(prompt)} + sx={{ mr: 0.5 }} + > + + + handleDeletePrompt(prompt.id)} + > + + + + + + ))} + + + )} + + + {/* System Prompts */} + + + + System Prompts + + } + label="Built-in" + size="small" + variant="outlined" + color="default" + /> + + + + + {systemPrompts.map((prompt, index) => ( + + {index > 0 && } + + handlePromptToggle(prompt.id, e.target.checked)} + size="small" + sx={{ mr: 1 }} + /> + + {prompt.content} + + } + placement="top-start" + arrow + enterDelay={500} + slotProps={{ + tooltip: { + sx: { maxWidth: 500, p: 1.5 } + } + }} + > + + {prompt.content} + + } + /> + + + handleViewPrompt(prompt)} + title="View" + > + + + + + + ))} + + + + + { + setDialogOpen(false); + setEditingPrompt(null); + }} + onSave={handleSavePrompt} + /> + + { + setViewDialogOpen(false); + setViewingPrompt(null); + }} + /> + + setSnackbarOpen(false)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setSnackbarOpen(false)}> + {snackbarMessage} + + +
+ ); +}; diff --git a/app/extension/src/components/ServerSettings.tsx b/app/extension/src/components/ServerSettings.tsx new file mode 100644 index 00000000..a01e507a --- /dev/null +++ b/app/extension/src/components/ServerSettings.tsx @@ -0,0 +1,649 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { + Alert, + Box, + Button, + Chip, + Collapse, + Divider, + FormControlLabel, + IconButton, + List, + ListItem, + ListItemText, + Paper, + Snackbar, + Switch, + TextField, + Typography, +} from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; +import AddIcon from '@mui/icons-material/Add'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import LockIcon from '@mui/icons-material/Lock'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import LoginIcon from '@mui/icons-material/Login'; +import { + readSyncStorageSettings, + ServerUrlItem, + getPromptsSettings, + savePromptsSettings, +} from '../storage'; +import { getLoginUserInfo, fetchEnabledShortcuts } from '../services'; + +interface ServerShortcut { + id: number; + name: string; + prompt: string; + enabled: boolean; +} + +interface UserInfo { + username: string; +} + +export type ServerSettingsProps = { + onSettingsChange?: () => void; +}; + +export const ServerSettings: React.FC = ({ + onSettingsChange, +}) => { + const [serverUrlList, setServerUrlList] = useState([ + { url: '' }, + ]); + const [enabledServerIndex, setEnabledServerIndex] = useState(null); + const [autoSaveEnabled, setAutoSaveEnabled] = useState(true); + const [autoSaveTweet, setAutoSaveTweet] = useState(false); + const [showSavedTip, setShowSavedTip] = useState(false); + const [urlErrors, setUrlErrors] = useState>({}); + + // Login state + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [userInfo, setUserInfo] = useState(null); + const [checkingLogin, setCheckingLogin] = useState(false); + + // Server shortcuts state + const [huntlyShortcutsEnabled, setHuntlyShortcutsEnabled] = useState(true); + const [serverShortcuts, setServerShortcuts] = useState([]); + const [loadingShortcuts, setLoadingShortcuts] = useState(false); + const [serverShortcutsExpanded, setServerShortcutsExpanded] = useState(false); + + // Check if a server is enabled (has a valid URL and is selected) + const isServerEnabled = enabledServerIndex !== null && + serverUrlList[enabledServerIndex]?.url?.trim() !== ''; + + const getServerUrl = useCallback(() => { + if (enabledServerIndex !== null && serverUrlList[enabledServerIndex]?.url) { + let url = serverUrlList[enabledServerIndex].url; + if (!url.endsWith('/')) { + url = url + '/'; + } + return url; + } + return ''; + }, [enabledServerIndex, serverUrlList]); + + const checkLoginStatus = useCallback(async () => { + if (!isServerEnabled) { + setIsLoggedIn(false); + setUserInfo(null); + return; + } + + setCheckingLogin(true); + try { + const response = await getLoginUserInfo(); + if (response) { + const user = JSON.parse(response); + // Check username to be consistent with popup.tsx login detection + if (user && user.username) { + setIsLoggedIn(true); + setUserInfo(user); + } else { + setIsLoggedIn(false); + setUserInfo(null); + } + } else { + setIsLoggedIn(false); + setUserInfo(null); + } + } catch (error) { + console.error('Failed to check login status:', error); + setIsLoggedIn(false); + setUserInfo(null); + } finally { + setCheckingLogin(false); + } + }, [isServerEnabled]); + + const loadServerShortcuts = useCallback(async () => { + if (!isServerEnabled || !isLoggedIn) { + setServerShortcuts([]); + return; + } + + setLoadingShortcuts(true); + try { + const shortcuts = await fetchEnabledShortcuts(); + setServerShortcuts(shortcuts || []); + } catch (error) { + console.error('Failed to load server shortcuts:', error); + setServerShortcuts([]); + } finally { + setLoadingShortcuts(false); + } + }, [isServerEnabled, isLoggedIn]); + + useEffect(() => { + readSyncStorageSettings().then((settings) => { + setAutoSaveEnabled(settings.autoSaveEnabled); + setAutoSaveTweet(settings.autoSaveTweet); + setHuntlyShortcutsEnabled(settings.huntlyShortcutsEnabled); + if (settings.serverUrlList.length > 0) { + setServerUrlList(settings.serverUrlList); + // Find the enabled server index, or null if none is enabled + let foundIndex: number | null = null; + settings.serverUrlList.forEach((item, index) => { + if (item.url === settings.serverUrl && settings.serverUrl) { + foundIndex = index; + } + }); + setEnabledServerIndex(foundIndex); + } + }); + }, []); + + // Check login status when server is enabled + useEffect(() => { + checkLoginStatus(); + }, [checkLoginStatus]); + + // Load shortcuts when logged in + useEffect(() => { + if (isLoggedIn) { + loadServerShortcuts(); + } + }, [isLoggedIn, loadServerShortcuts]); + + const validateUrl = (url: string): boolean => { + if (!url.trim()) return false; + const urlPattern = /^(?:([a-z0-9+.-]+):\/\/)(?:\S+(?::\S*)?@)?(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/; + return urlPattern.test(url); + }; + + const saveServerSettings = useCallback((urlList: ServerUrlItem[], enabledIndex: number | null) => { + const serverUrl = enabledIndex !== null ? (urlList[enabledIndex]?.url || '') : ''; + // Allow saving when no server is enabled (serverUrl is empty) or when a valid URL is selected + if (serverUrl === '' || validateUrl(serverUrl)) { + chrome.storage.sync.set({ + serverUrl, + serverUrlList: urlList, + }, () => { + setShowSavedTip(true); + onSettingsChange?.(); + // Re-check login status after server URL is saved to storage + checkLoginStatus(); + }); + } + }, [onSettingsChange, checkLoginStatus]); + + const handleUrlChange = (index: number, value: string) => { + const newList = [...serverUrlList]; + newList[index] = { url: value }; + setServerUrlList(newList); + + // Validate and clear error if valid + if (value && !validateUrl(value)) { + setUrlErrors({ ...urlErrors, [index]: 'Enter correct url!' }); + } else { + const newErrors = { ...urlErrors }; + delete newErrors[index]; + setUrlErrors(newErrors); + } + }; + + const handleUrlBlur = (index: number) => { + const url = serverUrlList[index]?.url; + if (url && validateUrl(url)) { + saveServerSettings(serverUrlList, enabledServerIndex); + } + }; + + const handleEnabledChange = (index: number) => { + // Toggle: if already enabled, disable it; otherwise enable it + const newIndex = enabledServerIndex === index ? null : index; + setEnabledServerIndex(newIndex); + saveServerSettings(serverUrlList, newIndex); + }; + + const handleAddUrl = () => { + setServerUrlList([...serverUrlList, { url: '' }]); + }; + + const handleRemoveUrl = (index: number) => { + const newList = serverUrlList.filter((_, i) => i !== index); + setServerUrlList(newList); + let newEnabledIndex: number | null = enabledServerIndex; + if (enabledServerIndex === index) { + // Removed the enabled server, disable all + newEnabledIndex = null; + } else if (enabledServerIndex !== null && enabledServerIndex > index) { + // Adjust index if removed item was before enabled one + newEnabledIndex = enabledServerIndex - 1; + } + setEnabledServerIndex(newEnabledIndex); + saveServerSettings(newList, newEnabledIndex); + }; + + const handleLogin = () => { + const serverUrl = getServerUrl(); + if (serverUrl) { + chrome.tabs.create({ url: serverUrl }); + } + }; + + const handleRefresh = async () => { + await checkLoginStatus(); + if (isLoggedIn) { + await loadServerShortcuts(); + } + }; + + const handleHuntlyShortcutsToggle = async (enabled: boolean) => { + setHuntlyShortcutsEnabled(enabled); + const settings = await getPromptsSettings(); + await savePromptsSettings({ + ...settings, + huntlyShortcutsEnabled: enabled, + }); + setShowSavedTip(true); + }; + + const handleOpenHuntlySettings = () => { + const serverUrl = getServerUrl(); + if (serverUrl) { + chrome.tabs.create({ url: `${serverUrl}settings/huntly-ai` }); + } + }; + + return ( +
+ setShowSavedTip(false)} + > + setShowSavedTip(false)}> + Settings saved. + + + +
+

Server Configuration

+

+ Configure your Huntly server URL.{' '} + + How to run the server > + +

+
+ +
+ {serverUrlList.map((item, index) => ( +
+ handleUrlChange(index, e.target.value)} + onBlur={() => handleUrlBlur(index)} + /> +
+ handleEnabledChange(index)} + /> +
+
+ {index === 0 ? ( + + + + ) : ( + handleRemoveUrl(index)} + size="small" + color="warning" + > + + + )} +
+
+ ))} +
+ + {/* Login Status - Only show when server is enabled */} + {isServerEnabled && ( + + + {checkingLogin ? ( + + Checking login status... + + ) : isLoggedIn && userInfo ? ( + + + + Logged in as {userInfo.username} + + + ) : ( + + + Not logged in + + + + )} + + + + + + )} + + {/* Auto Save Settings - Only show when server is enabled */} + {isServerEnabled && ( + + + Auto Save + + + Automatically save content to your Huntly server as you browse. + + + + + { + setAutoSaveEnabled(e.target.checked); + chrome.storage.sync.set({ autoSaveEnabled: e.target.checked }, () => { + setShowSavedTip(true); + }); + }} + /> + } + label={ + + Auto save articles + + Save articles after staying on a page for a while + + + } + sx={{ ml: 0, alignItems: 'flex-start', '& .MuiSwitch-root': { mt: 0.5 } }} + /> + + + + { + setAutoSaveTweet(e.target.checked); + chrome.storage.sync.set({ autoSaveTweet: e.target.checked }, () => { + setShowSavedTip(true); + }); + }} + /> + } + label={ + + Auto save tweets + + Save tweets from your timeline as you scroll on X + + + } + sx={{ ml: 0, alignItems: 'flex-start', '& .MuiSwitch-root': { mt: 0.5 } }} + /> + {isServerEnabled && ( + + + More Settings + + + + )} + + + + )} + + {/* Huntly Server AI Shortcuts - Only show when server is enabled */} + {isServerEnabled && ( + + + + + Huntly Server AI Shortcuts + + } + label="Read-only" + size="small" + variant="outlined" + color="default" + /> + + + handleHuntlyShortcutsToggle(e.target.checked)} + size="small" + disabled={!isLoggedIn} + /> + } + label="Enable" + sx={{ mr: 1 }} + /> + + + + + + + {!isLoggedIn ? ( + + + Please login to your Huntly server to use AI shortcuts. After logging in, + configure AI provider and shortcuts in{' '} + { + e.preventDefault(); + handleOpenHuntlySettings(); + }} + style={{ color: 'inherit', textDecoration: 'underline' }} + > + Huntly Server Settings + + . + + + ) : ( + + {loadingShortcuts ? ( + + Loading shortcuts... + + ) : serverShortcuts.length === 0 ? ( + + + No AI shortcuts configured on server. Configure shortcuts in{' '} + { + e.preventDefault(); + handleOpenHuntlySettings(); + }} + style={{ color: 'inherit', textDecoration: 'underline' }} + > + Huntly Server Settings + + . + + + ) : ( + <> + + + {(serverShortcutsExpanded ? serverShortcuts : serverShortcuts.slice(0, 3)).map((shortcut, index) => ( + + {index > 0 && } + + + {shortcut.prompt} + + } + /> + + + ))} + + + {serverShortcuts.length > 3 && ( + + )} + + + + + )} + + )} + + {isLoggedIn && !huntlyShortcutsEnabled && ( + + Huntly server shortcuts are disabled. Enable them to use server-side AI shortcuts. + + )} + + )} +
+ ); +}; diff --git a/app/extension/src/components/SettingsPage.tsx b/app/extension/src/components/SettingsPage.tsx new file mode 100644 index 00000000..bb56d835 --- /dev/null +++ b/app/extension/src/components/SettingsPage.tsx @@ -0,0 +1,107 @@ +import React, { useState } from 'react'; +import { + Box, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Typography, +} from '@mui/material'; +import SettingsIcon from '@mui/icons-material/Settings'; +import SmartToyIcon from '@mui/icons-material/SmartToy'; +import ArticleIcon from '@mui/icons-material/Article'; +import TextSnippetIcon from '@mui/icons-material/TextSnippet'; +import { ServerSettings } from './ServerSettings'; +import { AIProvidersSettings } from './AIProvidersSettings'; +import { ParserSettings } from './ParserSettings'; +import { PromptsSettings } from './PromptsSettings'; + +interface MenuItem { + id: string; + label: string; + icon: React.ReactNode; + component: React.ReactNode; +} + +export const SettingsPage: React.FC = () => { + // Read initial tab from URL hash (e.g., options.html#ai-providers) + const getInitialTab = () => { + const hash = window.location.hash.slice(1); // Remove '#' + const validTabs = ['server', 'ai-providers', 'prompts', 'parser']; + return validTabs.includes(hash) ? hash : 'server'; + }; + + const [activeMenu, setActiveMenu] = useState(getInitialTab); + + const menuItems: MenuItem[] = [ + { + id: 'server', + label: 'Server', + icon: , + component: , + }, + { + id: 'ai-providers', + label: 'AI Providers', + icon: , + component: , + }, + { + id: 'prompts', + label: 'Prompts', + icon: , + component: , + }, + { + id: 'parser', + label: 'Content Parser', + icon: , + component: , + }, + ]; + + const activeItem = menuItems.find((item) => item.id === activeMenu); + + return ( + + + + + Huntly + + Huntly + + + + + {menuItems.map((item) => ( + setActiveMenu(item.id)} + className="sidebar-nav-item" + > + + {item.icon} + + + + ))} + + + + + Huntly Extension v{chrome.runtime.getManifest().version} + + + + + + + {activeItem?.component} + + + + + ); +}; diff --git a/app/extension/src/components/ShadowDomPreview.tsx b/app/extension/src/components/ShadowDomPreview.tsx new file mode 100644 index 00000000..459d70ae --- /dev/null +++ b/app/extension/src/components/ShadowDomPreview.tsx @@ -0,0 +1,164 @@ +import React, { useEffect, useRef, createContext, useContext } from "react"; +import { createRoot, Root } from "react-dom/client"; +import createCache from "@emotion/cache"; +import { CacheProvider } from "@emotion/react"; +import { ThemeProvider, createTheme } from "@mui/material/styles"; +import { getShadowDomStyles } from "../styles/shadowDomStyles"; +import { ArticlePreview } from "./ArticlePreview"; +import { ContentParserType } from "../storage"; +import { ExternalShortcutsData, ExternalModelsData, ShortcutItem, ModelItem } from "./AIToolbar"; + +interface ShadowDomPreviewProps { + page: PageModel; + initialParserType: ContentParserType; + onClose: () => void; + onParserChange?: (parserType: ContentParserType, newPage: PageModel) => void; + /** Externally provided shortcuts data (for content script use) */ + externalShortcuts?: ExternalShortcutsData; + /** Externally provided models data (for content script use) */ + externalModels?: ExternalModelsData; + /** Shortcut to auto-execute on mount */ + autoExecuteShortcut?: ShortcutItem; + /** Model to use for auto-execute */ + autoSelectedModel?: ModelItem | null; +} + +// Context for Shadow DOM container (used by MUI Menu components) +export const ShadowContainerContext = createContext(null); + +// Hook to get the shadow container +export const useShadowContainer = () => useContext(ShadowContainerContext); + +interface ShadowContentProps { + emotionCache: ReturnType; + container: HTMLElement; + children: React.ReactNode; +} + +// Wrapper component that provides Emotion cache and MUI theme +const ShadowContent: React.FC = ({ emotionCache, container, children }) => { + const getContainer = React.useCallback(() => container, [container]); + + // Create theme with container pointing to shadow root + const theme = React.useMemo(() => createTheme({ + components: { + MuiPopover: { + defaultProps: { + container: getContainer, + }, + }, + MuiPopper: { + defaultProps: { + container: getContainer, + }, + }, + MuiModal: { + defaultProps: { + container: getContainer, + }, + }, + MuiMenu: { + defaultProps: { + PopoverClasses: {}, + }, + styleOverrides: { + paper: { + // Ensure menu paper has proper z-index in shadow DOM + zIndex: 99999, + }, + }, + }, + }, + }), [getContainer]); + + return ( + + + + {children} + + + + ); +}; + +export const ShadowDomPreview: React.FC = (props) => { + const hostRef = useRef(null); + const rootRef = useRef(null); + const emotionCacheRef = useRef | null>(null); + const containerRef = useRef(null); + + useEffect(() => { + if (!hostRef.current) return; + + // Create shadow root + const shadowRoot = hostRef.current.attachShadow({ mode: "open" }); + + // Inject styles into shadow DOM + const styleElement = document.createElement("style"); + styleElement.textContent = getShadowDomStyles(); + shadowRoot.appendChild(styleElement); + + // Create a container for Emotion styles + const emotionStyleContainer = document.createElement("div"); + emotionStyleContainer.id = "huntly-emotion-styles"; + shadowRoot.appendChild(emotionStyleContainer); + + // Create Emotion cache that injects styles into shadow DOM + emotionCacheRef.current = createCache({ + key: "huntly-mui", + container: emotionStyleContainer, + prepend: true, + }); + + // Create container for React content + const container = document.createElement("div"); + container.id = "huntly-shadow-content"; + container.style.cssText = ` + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 2147483647; + pointer-events: auto; + `; + shadowRoot.appendChild(container); + containerRef.current = container; + + // Create a new React root inside the Shadow DOM + // This ensures React's event delegation works correctly within the Shadow DOM boundary + rootRef.current = createRoot(container); + rootRef.current.render( + + + + ); + + // Cleanup + return () => { + if (rootRef.current) { + rootRef.current.unmount(); + rootRef.current = null; + } + }; + }, []); // Only run once on mount + + // Update the rendered content when props change + useEffect(() => { + // Skip if not initialized yet + if (!rootRef.current || !emotionCacheRef.current || !containerRef.current) return; + + rootRef.current.render( + + + + ); + }, [props]); + + return
; +}; + +export default ShadowDomPreview; + diff --git a/app/extension/src/languages.ts b/app/extension/src/languages.ts new file mode 100644 index 00000000..53244c07 --- /dev/null +++ b/app/extension/src/languages.ts @@ -0,0 +1,43 @@ +// Language definition with English name and native name +export type LanguageOption = { + code: string; // ISO language code + english: string; // English name + native: string; // Native name in that language +} + +// Language list sorted by global usage/popularity +export const LANGUAGES: LanguageOption[] = [ + { code: 'en', english: 'English', native: 'English' }, + { code: 'zh-Hans', english: 'Chinese (Simplified)', native: '简体中文' }, + { code: 'zh-Hant', english: 'Chinese (Traditional)', native: '繁體中文' }, + { code: 'es', english: 'Spanish', native: 'Español' }, + { code: 'ja', english: 'Japanese', native: '日本語' }, + { code: 'ko', english: 'Korean', native: '한국어' }, + { code: 'fr', english: 'French', native: 'Français' }, + { code: 'de', english: 'German', native: 'Deutsch' }, + { code: 'pt', english: 'Portuguese', native: 'Português' }, + { code: 'ru', english: 'Russian', native: 'Русский' }, + { code: 'ar', english: 'Arabic', native: 'العربية' }, + { code: 'it', english: 'Italian', native: 'Italiano' }, + { code: 'nl', english: 'Dutch', native: 'Nederlands' }, + { code: 'pl', english: 'Polish', native: 'Polski' }, + { code: 'vi', english: 'Vietnamese', native: 'Tiếng Việt' }, + { code: 'th', english: 'Thai', native: 'ไทย' }, + { code: 'id', english: 'Indonesian', native: 'Bahasa Indonesia' }, + { code: 'tr', english: 'Turkish', native: 'Türkçe' }, + { code: 'hi', english: 'Hindi', native: 'हिन्दी' }, + { code: 'bn', english: 'Bengali', native: 'বাংলা' }, + { code: 'uk', english: 'Ukrainian', native: 'Українська' }, + { code: 'cs', english: 'Czech', native: 'Čeština' }, + { code: 'sv', english: 'Swedish', native: 'Svenska' }, + { code: 'da', english: 'Danish', native: 'Dansk' }, + { code: 'fi', english: 'Finnish', native: 'Suomi' }, + { code: 'no', english: 'Norwegian', native: 'Norsk' }, + { code: 'el', english: 'Greek', native: 'Ελληνικά' }, + { code: 'he', english: 'Hebrew', native: 'עברית' }, + { code: 'ro', english: 'Romanian', native: 'Română' }, + { code: 'hu', english: 'Hungarian', native: 'Magyar' }, + { code: 'ms', english: 'Malay', native: 'Bahasa Melayu' }, + { code: 'fa', english: 'Persian', native: 'فارسی' }, +]; + diff --git a/app/extension/src/model.d.ts b/app/extension/src/model.d.ts index ea9a9d31..ece8a9a2 100644 --- a/app/extension/src/model.d.ts +++ b/app/extension/src/model.d.ts @@ -26,7 +26,8 @@ interface ShortcutPayload { } interface Message { - type: "auto_save_clipper" | "save_clipper" | 'tab_complete' | 'auto_save_tweets' | 'read_tweet' | 'parse_doc' | 'save_clipper_success' | 'shortcuts_preview' | 'shortcuts_process' | 'shortcuts_cancel' | 'shortcuts_processing_start' | 'shortcuts_process_result' | 'shortcuts_process_data' | 'shortcuts_process_error' | 'get_selection' | 'detect_rss_feed', - payload?: any + type: "auto_save_clipper" | "save_clipper" | 'tab_complete' | 'auto_save_tweets' | 'read_tweet' | 'parse_doc' | 'save_clipper_success' | 'shortcuts_preview' | 'shortcuts_execute' | 'shortcuts_process' | 'shortcuts_cancel' | 'shortcuts_processing_start' | 'shortcuts_process_result' | 'shortcuts_process_data' | 'shortcuts_process_error' | 'get_selection' | 'detect_rss_feed' | 'get_huntly_shortcuts' | 'get_ai_toolbar_data' | 'open_tab', + payload?: any, + url?: string } diff --git a/app/extension/src/options.css b/app/extension/src/options.css index dbf56b5f..f48023b5 100644 --- a/app/extension/src/options.css +++ b/app/extension/src/options.css @@ -1,8 +1,231 @@ +@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap'); + @tailwind base; @tailwind components; @tailwind utilities; +:root { + --huntly-ink: #1e293b; + --huntly-subtle: #475569; + --huntly-line: #e2e8f0; + --huntly-surface: #ffffff; + --huntly-soft: #f8fafc; + --huntly-accent: #3b82f6; + --huntly-accent-weak: #dbeafe; + --huntly-cta: #f97316; +} + .formHeader { - @apply text-lg font-medium text-gray-700 pb-2 mb-2; - border-bottom: 1px solid #ccc; + @apply text-lg font-medium text-gray-700 pb-2 mb-2; + border-bottom: 1px solid #ccc; +} + +/* Main Layout */ +.settings-layout { + display: flex; + min-height: 100vh; + background: var(--huntly-soft); + justify-content: center; + padding: 48px 24px; + box-sizing: border-box; + width: 100%; +} + +.settings-shell { + display: flex; + width: 100%; + max-width: 1080px; + background: var(--huntly-surface); + border-radius: 18px; + overflow: hidden; + box-shadow: none; + border: 1px solid var(--huntly-line); + box-sizing: border-box; +} + +/* Sidebar */ +.settings-sidebar { + width: 240px; + background: var(--huntly-soft); + border-right: 1px solid var(--huntly-line); + display: flex; + flex-direction: column; +} + +.sidebar-header { + display: flex; + align-items: center; + padding: 20px 20px; + border-bottom: 1px solid var(--huntly-line); +} + +.sidebar-logo { + color: var(--huntly-accent); + font-size: 28px !important; + margin-right: 8px; +} + +.sidebar-title { + font-weight: 600 !important; + color: var(--huntly-ink); +} + +.sidebar-nav { + flex: 1; + padding: 12px 12px 20px !important; +} + +.sidebar-nav-item { + border-radius: 8px !important; + margin-bottom: 4px !important; + padding: 10px 16px !important; + transition: all 0.15s ease !important; + color: var(--huntly-subtle); +} + +.sidebar-nav-item:hover { + background: #e2e8f0 !important; +} + +.sidebar-nav-item.Mui-selected { + background: var(--huntly-accent-weak) !important; + color: #0284c7 !important; +} + +.sidebar-nav-item.Mui-selected:hover { + background: #bae6fd !important; +} + +.sidebar-nav-item.Mui-selected .sidebar-nav-icon { + color: #0284c7 !important; +} + +.sidebar-nav-icon { + min-width: 40px !important; + color: #64748b !important; +} + +.sidebar-footer { + padding: 16px; + border-top: 1px solid var(--huntly-line); + text-align: center; +} + +/* Main Content Area */ +.settings-main { + flex: 1; + display: flex; + justify-content: center; + padding: 44px 36px; + background: var(--huntly-surface); +} + +.settings-content { + width: 100%; + max-width: 720px; +} + +/* Section Styles */ +.settings-section { + background: var(--huntly-surface); + border-radius: 12px; + padding: 28px 32px; + box-shadow: none; + border: 1px solid var(--huntly-line); +} + +.section-header { + margin-bottom: 20px; +} + +.section-title { + font-size: 1.25rem; + font-weight: 600; + color: var(--huntly-ink); + margin: 0 0 6px; +} + +.section-description { + font-size: 0.875rem; + color: var(--huntly-subtle); + margin: 0; + line-height: 1.5; +} + +.section-description a { + color: #0284c7; + text-decoration: none; + font-weight: 500; +} + +.section-description a:hover { + text-decoration: underline; +} + +/* Form Elements */ +.setting-item { + margin-top: 8px; +} + +.mt-3 { + margin-top: 12px; +} + +.mt-4 { + margin-top: 16px; +} + +.mt-6 { + margin-top: 24px; +} + +/* Responsive adjustments */ +@media (max-width: 900px) { + .settings-layout { + padding: 32px 16px; + } + + .settings-sidebar { + width: 200px; + } + + .settings-main { + padding: 28px 20px; + } +} + +@media (max-width: 600px) { + .settings-layout { + padding: 24px 12px; + } + + .settings-shell { + flex-direction: column; + } + + .settings-sidebar { + width: 100%; + border-right: none; + border-bottom: 1px solid #e2e8f0; + } + + .sidebar-nav { + display: flex; + overflow-x: auto; + padding: 8px !important; + } + + .sidebar-nav-item { + flex-shrink: 0; + margin-bottom: 0 !important; + margin-right: 4px !important; + } + + .sidebar-footer { + display: none; + } + + .settings-main { + padding: 22px 16px; + } } diff --git a/app/extension/src/options.tsx b/app/extension/src/options.tsx index 0bf94c23..24419829 100644 --- a/app/extension/src/options.tsx +++ b/app/extension/src/options.tsx @@ -1,15 +1,30 @@ -import React from "react"; -import {createRoot} from "react-dom/client"; -import {Settings} from "./settings"; -import {CssBaseline, StyledEngineProvider} from "@mui/material"; +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { SettingsPage } from './components/SettingsPage'; +import { CssBaseline, StyledEngineProvider, ThemeProvider, createTheme } from '@mui/material'; +import './options.css'; -const root = createRoot( - document.getElementById("root") as HTMLElement -); +const theme = createTheme({ + palette: { + primary: { + main: '#3B82F6', + }, + secondary: { + main: '#60A5FA', + }, + }, + typography: { + fontFamily: '"Plus Jakarta Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + }, +}); + +const root = createRoot(document.getElementById('root') as HTMLElement); root.render( - - + + + + ); diff --git a/app/extension/src/parser/contentParser.ts b/app/extension/src/parser/contentParser.ts new file mode 100644 index 00000000..9894db0c --- /dev/null +++ b/app/extension/src/parser/contentParser.ts @@ -0,0 +1,91 @@ +import { Readability } from "@mozilla/readability"; +import Defuddle from "defuddle"; +import { ContentParserType } from "../storage"; + +export interface ParsedArticle { + title: string; + content: string; + excerpt: string; + byline: string; + siteName: string; +} + +/** + * Parse document content using the specified parser + * @param doc - The document to parse (should be a cloned document) + * @param parserType - The parser to use: "readability" or "defuddle" + * @returns Parsed article or null if parsing fails + */ +export function parseDocument( + doc: Document, + parserType: ContentParserType = "readability" +): ParsedArticle | null { + if (parserType === "defuddle") { + return parseWithDefuddle(doc); + } + return parseWithReadability(doc); +} + +/** + * Extract excerpt from HTML content + * @param html - The HTML content + * @param maxLength - Maximum length of excerpt (default 200) + */ +function extractExcerptFromContent(html: string, maxLength: number = 200): string { + if (!html) return ""; + // Create a temporary element to parse HTML + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = html; + // Get text content and clean up whitespace + const text = (tempDiv.textContent || tempDiv.innerText || "") + .replace(/\s+/g, " ") + .trim(); + // Return truncated text + if (text.length <= maxLength) return text; + return text.substring(0, maxLength).trim() + "..."; +} + +/** + * Parse document using Mozilla Readability + */ +function parseWithReadability(doc: Document): ParsedArticle | null { + const article = new Readability(doc, { debug: false }).parse(); + if (!article) { + return null; + } + // Ensure excerpt has a fallback value from content + const excerpt = article.excerpt || extractExcerptFromContent(article.content || ""); + return { + title: article.title || "", + content: article.content || "", + excerpt, + byline: article.byline || "", + siteName: article.siteName || "", + }; +} + +/** + * Parse document using Defuddle + */ +function parseWithDefuddle(doc: Document): ParsedArticle | null { + try { + const defuddle = new Defuddle(doc); + const result = defuddle.parse(); + if (!result || !result.content) { + return null; + } + // Ensure excerpt has a fallback value from content + const excerpt = result.description || extractExcerptFromContent(result.content || ""); + return { + title: result.title || "", + content: result.content || "", + excerpt, + byline: result.author || "", + siteName: result.site || "", + }; + } catch (error) { + console.error("Defuddle parsing error:", error); + return null; + } +} + diff --git a/app/extension/src/popup.tsx b/app/extension/src/popup.tsx index 00b0f3e9..8806b815 100644 --- a/app/extension/src/popup.tsx +++ b/app/extension/src/popup.tsx @@ -1,7 +1,6 @@ import React, {useEffect, useState} from "react"; import { createRoot } from 'react-dom/client'; import './popup.css'; -import EnergySavingsLeafIcon from "@mui/icons-material/EnergySavingsLeaf"; import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'; import { Alert, @@ -11,12 +10,12 @@ import { CardContent, CardMedia, CircularProgress, CssBaseline, Dialog, DialogActions, DialogTitle, - IconButton, Menu, MenuItem, StyledEngineProvider, + IconButton, StyledEngineProvider, Tabs, Tab, TextField, Tooltip, Typography } from "@mui/material"; -import {readSyncStorageSettings, StorageSettings} from "./storage"; +import {readSyncStorageSettings, StorageSettings, ContentParserType} from "./storage"; import {combineUrl} from "./utils"; import PersonPinIcon from '@mui/icons-material/PersonPin'; import ArticleIcon from '@mui/icons-material/Article'; @@ -30,8 +29,7 @@ import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; import StarIcon from '@mui/icons-material/Star'; import ArchiveIcon from '@mui/icons-material/Archive'; import ArchiveOutlinedIcon from '@mui/icons-material/ArchiveOutlined'; -import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; -import ContentCutIcon from '@mui/icons-material/ContentCut'; +import MenuBookTwoToneIcon from '@mui/icons-material/MenuBookTwoTone'; import {log} from "./logger"; import { archivePage, @@ -42,22 +40,51 @@ import { saveArticle, savePageToLibrary, starPage, unReadLaterPage, unStarPage, - fetchEnabledShortcuts } from "./services"; import {LibrarySaveStatus} from "./model/librarySaveStatus"; import {PageOperateResult} from "./model/pageOperateResult"; -import {Settings} from "./settings"; import {detectRssFeed, RssSubscription} from "./rss"; +import AIToolbar, { ShortcutItem, ModelItem, AIGradientDef } from "./components/AIToolbar"; + +// Parser selector component - only shows the alternative parser option +const ParserSelector = ({ parserType, onParserChange }: { + parserType: ContentParserType; + onParserChange: (parser: ContentParserType) => void; +}) => { + const alternativeParser = parserType === 'readability' ? 'defuddle' : 'readability'; + const alternativeLabel = alternativeParser === 'readability' ? 'Readability' : 'Defuddle'; + + return ( + + + Try another parser: + + + + ); +}; const Popup = () => { const [storageSettings, setStorageSettings] = useState(null); - const [showOptions, setShowOptions] = useState(false); const [username, setUsername] = useState(null); const [loadingUser, setLoadingUser] = useState(true); const [page, setPage] = useState(null); const [autoSavedPageId, setAutoSavedPageId] = useState(0); const [articleOperateResult, setArticleOperateResult] = useState(null); + // Parser state + const [parserType, setParserType] = useState("readability"); + const [parsingArticle, setParsingArticle] = useState(false); + const [parseFailed, setParseFailed] = useState(false); + const [isHuntlySite, setIsHuntlySite] = useState(false); + // Tabs const [activeTab, setActiveTab] = useState(0); const [snippetPage, setSnippetPage] = useState(null); @@ -70,12 +97,8 @@ const Popup = () => { const activeOperateResult = activeTab === 0 ? articleOperateResult : snippetOperateResult; const setActiveOperateResult = activeTab === 0 ? setArticleOperateResult : setSnippetOperateResult; - // 快捷指令相关状态 - const [shortcuts, setShortcuts] = useState([]); - const [loadingShortcuts, setLoadingShortcuts] = useState(false); + // AI processing state const [processingShortcut, setProcessingShortcut] = useState(false); - const [shortcutMenuAnchorEl, setShortcutMenuAnchorEl] = useState(null); - const shortcutMenuOpen = Boolean(shortcutMenuAnchorEl); // RSS Feed Detection const [isRssFeed, setIsRssFeed] = useState(false); @@ -121,8 +144,14 @@ const Popup = () => { function setSettingsState(settings: StorageSettings) { setStorageSettings(settings); + // Initialize parserType from user settings + if (settings.contentParser) { + setParserType(settings.contentParser); + } if (!settings.serverUrl) { - setShowOptions(true); + // No server enabled - still load page info for parsing, but skip server-related operations + setLoadingUser(false); + loadPageInfoOnly(); } else { setLoadingUser(true); getLoginUserInfo().then((data) => { @@ -130,8 +159,6 @@ const Popup = () => { setUsername(result.username); loadPageInfo(); - // 加载快捷指令 - fetchShortcuts(); }).catch(() => { setUsername(null); }).finally(() => { @@ -140,16 +167,87 @@ const Popup = () => { } } - function loadPageInfo() { + function loadPageInfoOnly(customParserType?: ContentParserType, keepPageOnFail?: boolean) { + setParsingArticle(true); + setParseFailed(false); + chrome.tabs.query({active: true, currentWindow: true}, function (tabs) { + const tab = tabs[0]; + if (tab) { + chrome.tabs.sendMessage(tab.id, { + type: 'parse_doc', + payload: { parserType: customParserType } + }, function (response) { + setParsingArticle(false); + if(response) { + setIsHuntlySite(response.isHuntlySite === true); + if (response.page) { + setPage(response.page); + if (response.parserType) { + setParserType(response.parserType); + } + } else { + // Only clear page if not keeping on fail (initial load vs parser switch) + if (!keepPageOnFail) { + setPage(null); + } + setParseFailed(true); + } + } else { + if (!keepPageOnFail) { + setPage(null); + } + setParseFailed(true); + } + }); + } else { + setParsingArticle(false); + if (!keepPageOnFail) { + setPage(null); + } + setParseFailed(true); + } + }); + } + + function loadPageInfo(customParserType?: ContentParserType, keepPageOnFail?: boolean) { + setParsingArticle(true); + setParseFailed(false); chrome.tabs.query({active: true, currentWindow: true}, function (tabs) { const tab = tabs[0]; if (tab) { - chrome.tabs.sendMessage(tab.id, {type: 'parse_doc'}, function (response) { + chrome.tabs.sendMessage(tab.id, { + type: 'parse_doc', + payload: { parserType: customParserType } + }, function (response) { + setParsingArticle(false); if(response) { - setPage(response.page); - loadPageOperateResult(autoSavedPageId, response.page.url, setArticleOperateResult); + setIsHuntlySite(response.isHuntlySite === true); + if (response.page) { + setPage(response.page); + if (response.parserType) { + setParserType(response.parserType); + } + loadPageOperateResult(autoSavedPageId, response.page.url, setArticleOperateResult); + } else { + // Only clear page if not keeping on fail (initial load vs parser switch) + if (!keepPageOnFail) { + setPage(null); + } + setParseFailed(true); + } + } else { + if (!keepPageOnFail) { + setPage(null); + } + setParseFailed(true); } }); + } else { + setParsingArticle(false); + if (!keepPageOnFail) { + setPage(null); + } + setParseFailed(true); } }); } @@ -198,8 +296,8 @@ const Popup = () => { }); } - function toggleShowOptions() { - setShowOptions(!showOptions); + function openOptionsPage() { + chrome.runtime.openOptionsPage(); } function getDomain(serverUrl: string) { @@ -207,11 +305,6 @@ const Popup = () => { return url.hostname; } - function handleOptionsChange(settings: StorageSettings) { - setShowOptions(false); - setSettingsState(settings); - } - function openSignIn() { chrome.tabs.create({url: combineUrl(storageSettings.serverUrl, "/signin")}); } @@ -351,107 +444,83 @@ const Popup = () => {
; } - function articlePreview() { - chrome.tabs.query({active: true, currentWindow: true}, function (tabs) { - const tab = tabs[0]; - if (tab) { - chrome.tabs.sendMessage(tab.id, { - type: 'shortcuts_preview', - payload: { - shortcuts: shortcuts, - page: activePage - } - }, function (response) { - }); + async function articlePreview() { + const tabs = await chrome.tabs.query({active: true, currentWindow: true}); + const tab = tabs[0]; + if (!tab) return; + + // Get AI toolbar data from background script for content script use + const aiToolbarData = await new Promise((resolve) => { + chrome.runtime.sendMessage({ type: 'get_ai_toolbar_data' }, (response) => { + if (chrome.runtime.lastError) { + console.error('Failed to get AI toolbar data:', chrome.runtime.lastError); + resolve({ success: false }); + } else { + resolve(response || { success: false }); + } + }); + }); - // 关闭 popup 窗口 - window.close(); + log('[Huntly] articlePreview - parserType:', parserType); + chrome.tabs.sendMessage(tab.id, { + type: 'shortcuts_preview', + payload: { + page: activePage, + parserType: parserType, + externalShortcuts: aiToolbarData.success ? aiToolbarData.externalShortcuts : undefined, + externalModels: aiToolbarData.success ? aiToolbarData.externalModels : undefined, } }); - } - // 获取可用的快捷指令 - async function fetchShortcuts() { - try { - setLoadingShortcuts(true); - const data = await fetchEnabledShortcuts(); - setShortcuts(data); - } catch (error) { - console.error("Error fetching shortcuts:", error); - } finally { - setLoadingShortcuts(false); - } + // Close popup window + window.close(); } - // 处理快捷指令菜单打开 - const handleShortcutMenuOpen = (event: React.MouseEvent) => { - setShortcutMenuAnchorEl(event.currentTarget); - }; - - // 处理快捷指令菜单关闭 - const handleShortcutMenuClose = () => { - setShortcutMenuAnchorEl(null); - }; - - // 处理快捷指令点击 - const handleShortcutClick = async (shortcutId: number, shortcutName: string) => { + // Handle AI shortcut click from AIToolbar + const handleAIShortcutClick = async (shortcut: ShortcutItem, selectedModel: ModelItem | null) => { if (!activePage || processingShortcut) return; - + setProcessingShortcut(true); - handleShortcutMenuClose(); - - try { - // 获取当前活动标签页 - chrome.tabs.query({active: true, currentWindow: true}, function (tabs) { - const tab = tabs[0]; - if (tab) { - // 生成唯一的 taskId - const taskId = `popup_${Date.now()}_${Math.random().toString(36).substring(7)}`; - - // Snippet 模式下使用 snippet 的内容和 description(纯文本) - const isSnippetMode = activeTab === 1; - const contentToProcess = isSnippetMode - ? (activePage.description || activePage.content) // Snippet 使用 description(选中的纯文本) - : activePage.content; - - // 发送消息到 background 脚本处理快捷指令 - chrome.runtime.sendMessage({ - type: 'shortcuts_process', - payload: { - tabId: tab.id, - taskId, - shortcutId, - shortcutName, - content: contentToProcess, - url: activePage.url, - title: isSnippetMode ? '' : activePage.title, - contentType: isSnippetMode ? 4 : undefined, // 4 = SNIPPET - shortcuts: shortcuts - } - }); - - // 关闭 popup 窗口 - window.close(); + + const tabs = await chrome.tabs.query({active: true, currentWindow: true}); + const tab = tabs[0]; + if (!tab) return; + + // Get AI toolbar data from background script for content script use + const aiToolbarData = await new Promise((resolve) => { + chrome.runtime.sendMessage({ type: 'get_ai_toolbar_data' }, (response) => { + if (chrome.runtime.lastError) { + console.error('Failed to get AI toolbar data:', chrome.runtime.lastError); + resolve({ success: false }); + } else { + resolve(response || { success: false }); } }); - } catch (error) { - console.error("Error processing with shortcut:", error); - setProcessingShortcut(false); - } + }); + + // Open preview with auto-execute shortcut + chrome.tabs.sendMessage(tab.id, { + type: 'shortcuts_preview', + payload: { + page: activePage, + parserType: parserType, + externalShortcuts: aiToolbarData.success ? aiToolbarData.externalShortcuts : undefined, + externalModels: aiToolbarData.success ? aiToolbarData.externalModels : undefined, + autoExecuteShortcut: shortcut, + autoSelectedModel: selectedModel, + } + }); + + // Close popup window + window.close(); }; return (
- - - - - - - +
- + Huntly Huntly { storageSettings && storageSettings.serverUrl &&
@@ -468,7 +537,7 @@ const Popup = () => { } - +
@@ -476,24 +545,15 @@ const Popup = () => {
{ - !storageSettings || !storageSettings.serverUrl &&
- Please set the huntly server url first. -
- } - { - showOptions &&
- -
- } - { - !showOptions &&
+
{ loadingUser &&
} + {/* Server configured but not signed in */} { - !loadingUser && !username &&
+ !loadingUser && storageSettings?.serverUrl && !username &&
Please sign in to start.
@@ -502,8 +562,9 @@ const Popup = () => {
} + {/* Server configured and signed in, or no server configured (read-only mode) */} { - !loadingUser && username &&
+ !loadingUser && (username || !storageSettings?.serverUrl) &&
{/* RSS Feed Subscription Interface */} {checkingRssFeed && (
@@ -534,10 +595,23 @@ const Popup = () => { {/* Article Tab Content */} } { - activeTab === 0 && autoSavedPageId > 0 &&
+ storageSettings?.serverUrl && !isHuntlySite && activeTab === 0 && autoSavedPageId > 0 &&
This webpage has been automatically hunted. view @@ -577,7 +651,7 @@ const Popup = () => {
} { - !autoSavedPageId && activeOperateResult && activeOperateResult.id > 0 &&
+ storageSettings?.serverUrl && !isHuntlySite && !autoSavedPageId && activeOperateResult && activeOperateResult.id > 0 &&
{activeTab === 0 ? 'This webpage has been hunted.' : 'This snippet has been saved.'} {
-
+ {/* Only show action buttons when server is configured and not on Huntly site */} + {storageSettings?.serverUrl && !isHuntlySite &&
{ activeOperateResult?.readLater ? ( @@ -636,7 +711,7 @@ const Popup = () => { ) ) } -
+
}
{ @@ -661,29 +736,34 @@ const Popup = () => { />
} - + {/* Article Card Content */} - {activeTab === 0 &&
- + {activeTab === 0 &&
+ {activePage.domain} - + {activePage.title} - + {activePage.description} + + +
} {/* Snippet Card Content */} - {activeTab === 1 &&
+ {activeTab === 1 &&
{activePage.title} @@ -692,43 +772,19 @@ const Popup = () => { {activePage.description} + + +
} - -
- - - {shortcuts && shortcuts.length > 0 && ( - <> - - - {shortcuts.map(shortcut => ( - handleShortcutClick(shortcut.id, shortcut.name)} - disabled={processingShortcut} - > - {shortcut.name} - - ))} - - - )} + +
+
diff --git a/app/extension/src/services.ts b/app/extension/src/services.ts index 037a7248..06406e4d 100644 --- a/app/extension/src/services.ts +++ b/app/extension/src/services.ts @@ -132,7 +132,7 @@ export async function fetchEnabledShortcuts(): Promise { try { const baseUri = await getApiBaseUrl(); const response = await getData(baseUri, "article-shortcuts/enabled"); - + if (response) { return JSON.parse(response); } @@ -142,3 +142,22 @@ export async function fetchEnabledShortcuts(): Promise { return []; } } + +/** + * Fetch global setting from the server + * @returns Promise with the global setting object + */ +export async function fetchGlobalSetting(): Promise { + try { + const baseUri = await getApiBaseUrl(); + const response = await getData(baseUri, "setting/general/globalSetting"); + + if (response) { + return JSON.parse(response); + } + return null; + } catch (error) { + console.error("Error fetching global setting:", error); + return null; + } +} diff --git a/app/extension/src/settings.tsx b/app/extension/src/settings.tsx index 7064c3ee..b7d322d3 100644 --- a/app/extension/src/settings.tsx +++ b/app/extension/src/settings.tsx @@ -5,7 +5,7 @@ import * as yup from 'yup'; import {FieldArray, Form, Formik, getIn} from "formik"; import DeleteIcon from "@mui/icons-material/Delete"; import AddIcon from '@mui/icons-material/Add'; -import {readSyncStorageSettings, ServerUrlItem, StorageSettings} from "./storage"; +import {ContentParserType, readSyncStorageSettings, ServerUrlItem, StorageSettings, DefaultStorageSettings} from "./storage"; export type SettingsProps = { onOptionsChange?: (settings:StorageSettings) => void @@ -16,14 +16,14 @@ export const Settings = ({onOptionsChange}: SettingsProps) => { const [enabledServerIndex, setEnabledServerIndex] = useState(0); const [autoSaveEnabled, setAutoSaveEnabled] = useState(true); const [autoSaveTweet, setAutoSaveTweet] = useState(false); - const [autoSaveTweetMinLikes, setAutoSaveTweetMinLikes] = useState(0); + const [contentParser, setContentParser] = useState("readability"); const [showSavedTip, setShowSavedTip] = useState(false); useEffect(() => { readSyncStorageSettings().then((settings) => { setAutoSaveEnabled(settings.autoSaveEnabled); setAutoSaveTweet(settings.autoSaveTweet); - setAutoSaveTweetMinLikes(settings.autoSaveTweetMinLikes); + setContentParser(settings.contentParser); if (settings.serverUrlList.length > 0) { setServerUrlList(settings.serverUrlList); settings.serverUrlList.forEach((item, index) => { @@ -44,7 +44,6 @@ export const Settings = ({onOptionsChange}: SettingsProps) => { })), autoSaveEnabled: yup.boolean().required('Auto save enabled is required.'), autoSaveTweet: yup.boolean().required('Auto save tweet is required.'), - autoSaveTweetMinLikes: yup.number().min(0).max(10000).required('Min likes is required.') }); return ( @@ -67,17 +66,17 @@ export const Settings = ({onOptionsChange}: SettingsProps) => { settings: serverUrlList, autoSaveEnabled: autoSaveEnabled, autoSaveTweet: autoSaveTweet, - autoSaveTweetMinLikes: autoSaveTweetMinLikes }} validationSchema={urlValidation} onSubmit={(values) => { const serverUrl = values.settings[enabledServerIndex].url; const storageSettings: StorageSettings = { + ...DefaultStorageSettings, "serverUrl": serverUrl, "serverUrlList": values.settings, "autoSaveEnabled": values.autoSaveEnabled, "autoSaveTweet": values.autoSaveTweet, - "autoSaveTweetMinLikes": values.autoSaveTweetMinLikes + "contentParser": contentParser }; chrome.storage.sync.set( { @@ -85,7 +84,7 @@ export const Settings = ({onOptionsChange}: SettingsProps) => { "serverUrlList": values.settings, "autoSaveEnabled": values.autoSaveEnabled, "autoSaveTweet": values.autoSaveTweet, - "autoSaveTweetMinLikes": values.autoSaveTweetMinLikes + "contentParser": contentParser }, () => { setShowSavedTip(true); @@ -183,18 +182,6 @@ export const Settings = ({onOptionsChange}: SettingsProps) => { } label="Enabled"/> -
- -
diff --git a/app/extension/src/storage.ts b/app/extension/src/storage.ts index feac715b..b2c61b64 100644 --- a/app/extension/src/storage.ts +++ b/app/extension/src/storage.ts @@ -1,19 +1,109 @@ +import { getLocalizedSystemPrompt } from './ai/system-prompts'; +import { LANGUAGES, LanguageOption } from './languages'; +export { getLocalizedSystemPrompt, SystemPromptContent } from './ai/system-prompts'; +export { LANGUAGES, LanguageOption } from './languages'; + export const STORAGE_SERVER_URL = "serverUrl"; export const STORAGE_SERVER_URL_LIST = "serverUrlList"; export const STORAGE_AUTO_SAVE_ENABLED = "autoSaveEnabled"; export const STORAGE_AUTO_SAVE_TWEET = "autoSaveTweet"; -export const STORAGE_AUTO_SAVE_TWEET_MIN_LIKES = "autoSaveTweetMinLikes"; +export const STORAGE_CONTENT_PARSER = "contentParser"; +export const STORAGE_DEFAULT_TARGET_LANGUAGE = "defaultTargetLanguage"; +export const STORAGE_USER_PROMPTS = "userPrompts"; // User-created prompts only +export const STORAGE_ENABLED_SYSTEM_PROMPTS = "enabledSystemPrompts"; // IDs of enabled system prompts +export const STORAGE_HUNTLY_SHORTCUTS_ENABLED = "huntlyShortcutsEnabled"; +export const STORAGE_SELECTED_MODEL_ID = "selectedModelId"; // Remember last selected model export type ServerUrlItem = { url: string, } +export type ContentParserType = "readability" | "defuddle"; + +export type Prompt = { + id: string; + name: string; + content: string; + targetLanguage: string; + enabled: boolean; + isSystem: boolean; + createdAt: number; + updatedAt: number; +} + +// Map browser language code to language option +export function getBrowserLanguage(): string { + const browserLang = navigator.language.toLowerCase(); + + // Handle Chinese variants specifically + if (browserLang.startsWith('zh')) { + // zh-TW, zh-HK, zh-Hant are Traditional Chinese + if (browserLang.includes('tw') || browserLang.includes('hk') || browserLang.includes('hant')) { + return 'Chinese (Traditional)'; + } + // zh-CN, zh-Hans, zh (default) are Simplified Chinese + return 'Chinese (Simplified)'; + } + + // For other languages, match by base code + const baseCode = browserLang.split('-')[0]; + const matched = LANGUAGES.find(lang => lang.code === baseCode); + return matched ? matched.english : 'English'; +} + +// Get native name for a language (for {lang} replacement) +export function getLanguageNativeName(english: string): string { + const lang = findLanguageByEnglish(english); + return lang ? lang.native : english; +} + +// Find language option by English name (case-insensitive) +export function findLanguageByEnglish(english: string): LanguageOption | undefined { + return LANGUAGES.find(lang => lang.english.toLowerCase() === english.toLowerCase()); +} + +export type PromptsSettings = { + defaultTargetLanguage: string; + prompts: Prompt[]; + huntlyShortcutsEnabled: boolean; +} + +// System prompt IDs in display order +const SYSTEM_PROMPT_IDS = [ + 'system_summarize', + 'system_translate', + 'system_bilingual_translate', + 'system_key_points', + 'system_action_items', + 'system_explain' +]; + +// Generate system prompts based on target language +export function getSystemPrompts(targetLanguage: string): Omit[] { + return SYSTEM_PROMPT_IDS.map(id => { + const localized = getLocalizedSystemPrompt(id, targetLanguage); + return { + id, + name: localized.name, + content: localized.content, + targetLanguage, + enabled: true, // All system prompts enabled by default + isSystem: true, + }; + }); +} + +// Legacy SYSTEM_PROMPTS for backward compatibility (uses English version) +export const SYSTEM_PROMPTS: Omit[] = getSystemPrompts('English'); + export type StorageSettings = { serverUrl: string; serverUrlList: ServerUrlItem[]; autoSaveEnabled: boolean; autoSaveTweet: boolean; - autoSaveTweetMinLikes: number; + contentParser: ContentParserType; + defaultTargetLanguage: string; + huntlyShortcutsEnabled: boolean; } export const DefaultStorageSettings: StorageSettings = { @@ -21,7 +111,9 @@ export const DefaultStorageSettings: StorageSettings = { serverUrlList: [], autoSaveEnabled: false, autoSaveTweet: false, - autoSaveTweetMinLikes: 0 + contentParser: "readability", + defaultTargetLanguage: "English", + huntlyShortcutsEnabled: true } export async function readSyncStorageSettings(): Promise { @@ -31,6 +123,86 @@ export async function readSyncStorageSettings(): Promise { serverUrlList: items[STORAGE_SERVER_URL_LIST] || DefaultStorageSettings.serverUrlList, autoSaveEnabled: items[STORAGE_AUTO_SAVE_ENABLED] ?? DefaultStorageSettings.autoSaveEnabled, autoSaveTweet: items[STORAGE_AUTO_SAVE_TWEET] ?? DefaultStorageSettings.autoSaveTweet, - autoSaveTweetMinLikes: items[STORAGE_AUTO_SAVE_TWEET_MIN_LIKES] ?? DefaultStorageSettings.autoSaveTweetMinLikes + contentParser: items[STORAGE_CONTENT_PARSER] || DefaultStorageSettings.contentParser, + defaultTargetLanguage: items[STORAGE_DEFAULT_TARGET_LANGUAGE] || DefaultStorageSettings.defaultTargetLanguage, + huntlyShortcutsEnabled: items[STORAGE_HUNTLY_SHORTCUTS_ENABLED] ?? DefaultStorageSettings.huntlyShortcutsEnabled }; +} + +export async function savePromptsSettings(settings: PromptsSettings): Promise { + // Extract only the necessary data for minimal storage + const userPrompts = settings.prompts.filter(p => !p.isSystem); + const enabledSystemPromptIds = settings.prompts + .filter(p => p.isSystem && p.enabled) + .map(p => p.id); + + await chrome.storage.sync.set({ + [STORAGE_DEFAULT_TARGET_LANGUAGE]: settings.defaultTargetLanguage, + [STORAGE_USER_PROMPTS]: userPrompts, + [STORAGE_ENABLED_SYSTEM_PROMPTS]: enabledSystemPromptIds, + [STORAGE_HUNTLY_SHORTCUTS_ENABLED]: settings.huntlyShortcutsEnabled + }); +} + +export async function getPromptsSettings(): Promise { + const items = await chrome.storage.sync.get({ + [STORAGE_DEFAULT_TARGET_LANGUAGE]: '', // Empty string to detect first load + [STORAGE_USER_PROMPTS]: [], + [STORAGE_ENABLED_SYSTEM_PROMPTS]: null, // null to detect if never set + [STORAGE_HUNTLY_SHORTCUTS_ENABLED]: DefaultStorageSettings.huntlyShortcutsEnabled + }); + + // Use browser language if not set, fallback to English + let targetLanguage: string = items[STORAGE_DEFAULT_TARGET_LANGUAGE] || getBrowserLanguage(); + + // Migrate legacy "Chinese" to "Chinese (Simplified)" + if (targetLanguage === 'Chinese') { + targetLanguage = 'Chinese (Simplified)'; + } + const now = Date.now(); + + // Get user prompts + const userPrompts: Prompt[] = items[STORAGE_USER_PROMPTS] || []; + + // Get enabled system prompt IDs (all enabled by default if not set) + const enabledSystemPromptIds: string[] | null = items[STORAGE_ENABLED_SYSTEM_PROMPTS]; + + // Get system prompts localized for the target language + const localizedSystemPrompts = getSystemPrompts(targetLanguage); + + // Build the final prompts list + const mergedPrompts: Prompt[] = []; + + // Add system prompts with correct enabled state + // If enabledSystemPromptIds is null (first time), all system prompts are enabled + const enabledSystemPromptSet = enabledSystemPromptIds ? new Set(enabledSystemPromptIds) : null; + + for (const systemPrompt of localizedSystemPrompts) { + mergedPrompts.push({ + ...systemPrompt, + enabled: enabledSystemPromptSet ? enabledSystemPromptSet.has(systemPrompt.id) : true, + createdAt: now, + updatedAt: now + }); + } + + // Add user prompts + mergedPrompts.push(...userPrompts); + + return { + defaultTargetLanguage: targetLanguage, + prompts: mergedPrompts, + huntlyShortcutsEnabled: items[STORAGE_HUNTLY_SHORTCUTS_ENABLED] + }; +} + +// Save selected model ID +export async function saveSelectedModelId(modelId: string): Promise { + await chrome.storage.sync.set({ [STORAGE_SELECTED_MODEL_ID]: modelId }); +} + +// Get saved selected model ID +export async function getSelectedModelId(): Promise { + const items = await chrome.storage.sync.get({ [STORAGE_SELECTED_MODEL_ID]: null }); + return items[STORAGE_SELECTED_MODEL_ID]; } \ No newline at end of file diff --git a/app/extension/src/styles/shadowDomStyles.ts b/app/extension/src/styles/shadowDomStyles.ts new file mode 100644 index 00000000..4cf6f4f4 --- /dev/null +++ b/app/extension/src/styles/shadowDomStyles.ts @@ -0,0 +1,414 @@ +// This file exports the CSS styles as a string for injection into Shadow DOM +// We use a separate file to avoid webpack's style-loader injecting into document head + +export const getShadowDomStyles = (): string => ` +/* Reset all inherited styles from host page to prevent external CSS interference */ +:host { + all: initial !important; + display: contents !important; +} + +/* Reset box-sizing for Shadow DOM - don't reset margin/padding to avoid affecting MUI components */ +*, *::before, *::after { + box-sizing: border-box; +} + +/* Ensure text selection is enabled */ +* { + -webkit-user-select: text !important; + -moz-user-select: text !important; + -ms-user-select: text !important; + user-select: text !important; +} + +/* Custom scrollbar styles to override any external page styles */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.3); +} + +/* Firefox scrollbar */ +* { + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.2) transparent; +} + +/* Shadow content container - reset all inherited styles */ +#huntly-shadow-content { + all: initial; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.5; + color: #333; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Modal overlay styles */ +.huntly-modal-overlay { + position: fixed !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + z-index: 999999 !important; + overflow: hidden !important; + overscroll-behavior: none !important; + background-color: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + /* Ensure modal overlay creates a new stacking context */ + isolation: isolate; +} + +/* Modal content */ +.huntly-modal-content { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: white; + border: none; + padding: 0; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.huntly-modal-inner { + position: relative; + height: 100%; + overflow: hidden; + touch-action: none; +} + +/* Header bar */ +.huntly-header-bar { + background-color: rgba(255, 255, 255, 0.98); + backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + padding: 12px 24px; + display: flex; + align-items: center; + justify-content: space-between; + position: relative; + z-index: 1000; + flex-shrink: 0; +} + +/* Content area */ +.huntly-content-area { + display: flex; + flex: 1; + overflow: hidden; + height: calc(100% - 60px); + position: relative; + z-index: 1; +} + +/* Article section */ +.huntly-article-section { + transition: width 0.3s ease; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* Scroll container */ +.huntly-scroll-container { + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + height: 100%; + max-height: 100%; + padding: 24px; + position: relative; + z-index: 0; + /* Contain fixed/sticky nodes from parsed HTML so they cannot cover the header toolbar. */ + transform: translateZ(0); + /* Create a new stacking context to isolate internal positioned elements */ + isolation: isolate; +} + +/* Ensure elements inside scroll container don't escape and block text selection */ +.huntly-scroll-container * { + /* Reset any fixed/absolute positioning from external content that might escape */ + max-width: 100%; +} + +/* Force fixed/absolute elements inside scroll container to stay contained */ +.huntly-scroll-container [style*="position: fixed"], +.huntly-scroll-container [style*="position:fixed"] { + position: absolute !important; +} + +/* Ensure article content allows text selection */ +.huntly-scroll-container article, +.huntly-scroll-container .huntly-markdown-body { + position: relative; + z-index: 1; +} + +/* Processed section */ +.huntly-processed-section { + width: 50%; + border-left: 1px solid #e0e0e0; + background-color: #ffffff; + transition: all 0.3s ease; + overflow: hidden; + display: flex; + flex-direction: column; + position: relative; +} + +/* Loading animation */ +.huntly-loading-placeholder { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 24px; +} + +.huntly-loading-bar { + height: 12px; + background-color: #e2e8f0; + border-radius: 6px; + animation: huntly-pulse 1.5s ease-in-out infinite; +} + +@keyframes huntly-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Header right section */ +.huntly-header-right { + display: flex; + align-items: center; + gap: 12px; + margin-left: auto; +} + +/* Parser selector */ +.huntly-parser-selector { + display: flex; + align-items: center; + gap: 8px; +} + +.huntly-parser-label { + font-size: 12px; + color: #666; + white-space: nowrap; + font-weight: 500; +} + +.huntly-parser-select { + padding: 5px 24px 5px 10px; + font-size: 12px; + border: 1px solid #e0e0e0; + border-radius: 6px; + background: #fafafa url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E") no-repeat right 8px center; + cursor: pointer; + outline: none; + min-width: 100px; + color: #333; + transition: border-color 0.2s, background-color 0.2s; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + +.huntly-parser-select:hover { + border-color: #bbb; + background-color: #f5f5f5; +} + +.huntly-parser-select:focus { + border-color: #1976d2; + box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.15); +} + +/* Close button */ +.huntly-close-button { + width: 32px; + height: 32px; + border: none; + background: rgba(0, 0, 0, 0.05); + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; + flex-shrink: 0; +} + +.huntly-close-button:hover { + background: rgba(0, 0, 0, 0.1); +} + +.huntly-close-button svg { + width: 18px; + height: 18px; + color: #666; +} + +/* Markdown body styles */ +.huntly-markdown-body { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + margin: 0; + color: #24292f; + background-color: #ffffff; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.5; + word-wrap: break-word; +} + +.huntly-markdown-body a { + color: #0969da; + text-decoration: none; +} + +.huntly-markdown-body a:hover { + text-decoration: underline; +} + +.huntly-markdown-body h1 { + margin: 0 0 .67em 0; + font-weight: 600; + padding-bottom: .3em; + font-size: 2em; + border-bottom: 1px solid #d0d7de; +} + +.huntly-markdown-body h2 { + font-weight: 600; + padding-bottom: .3em; + font-size: 1.5em; + border-bottom: 1px solid #d0d7de; + margin-top: 24px; + margin-bottom: 16px; +} + +.huntly-markdown-body h3 { + font-weight: 600; + font-size: 1.25em; + margin-top: 24px; + margin-bottom: 16px; +} + +.huntly-markdown-body h4, .huntly-markdown-body h5, .huntly-markdown-body h6 { + font-weight: bold; + margin-top: 24px; + margin-bottom: 16px; +} + +.huntly-markdown-body p { + display: block; + margin-block-start: 1em; + margin-block-end: 1em; +} + +.huntly-markdown-body img { + max-width: 100%; + height: auto; +} + +.huntly-markdown-body blockquote { + margin: 0; + padding: 0 1em; + color: #57606a; + border-left: .25em solid #d0d7de; + margin-bottom: 1em; +} + +.huntly-markdown-body ul, .huntly-markdown-body ol { + margin-block-start: 1em; + margin-block-end: 1em; + padding-left: 2em; +} + +.huntly-markdown-body code { + padding: .2em .4em; + margin: 0; + font-size: 85%; + background-color: rgba(175, 184, 193, 0.2); + border-radius: 6px; + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace; +} + +.huntly-markdown-body pre { + padding: 16px; + overflow: auto; + font-size: 85%; + line-height: 1.45; + background-color: #f6f8fa; + border-radius: 6px; + white-space: pre-wrap; + font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace; +} + +.huntly-markdown-body pre code { + padding: 0; + margin: 0; + background: transparent; + border: 0; + font-size: 100%; +} + +.huntly-markdown-body table { + border-spacing: 0; + border-collapse: collapse; + max-width: 100%; + margin-bottom: 16px; +} + +.huntly-markdown-body table th, +.huntly-markdown-body table td { + padding: 6px 13px; + border: 1px solid #d0d7de; +} + +.huntly-markdown-body table th { + font-weight: 600; + background-color: #f6f8fa; +} + +.huntly-markdown-body hr { + height: .25em; + padding: 0; + margin: 24px 0; + background-color: #d0d7de; + border: 0; +} + +.huntly-markdown-body figure { + margin: 1em 40px; +} + +.huntly-markdown-body figcaption { + font-size: 0.9em; + color: #57606a; + text-align: center; + margin-top: 8px; +} +`; + +export default getShadowDomStyles; diff --git a/app/extension/src/web_clipper.tsx b/app/extension/src/web_clipper.tsx index ca5f8c3f..14c4426e 100644 --- a/app/extension/src/web_clipper.tsx +++ b/app/extension/src/web_clipper.tsx @@ -1,49 +1,97 @@ -import {isProbablyReaderable, Readability} from "@mozilla/readability"; +import {isProbablyReaderable} from "@mozilla/readability"; import {findSmallestFaviconUrl, getBaseURI, isNotBlank, toAbsoluteURI} from "./utils"; import {log} from "./logger"; -import {readSyncStorageSettings} from "./storage"; +import {ContentParserType, readSyncStorageSettings} from "./storage"; +import {parseDocument} from "./parser/contentParser"; import React from "react"; -import Article from "./article"; +import {ShadowDomPreview} from "./components/ShadowDomPreview"; import {createRoot} from "react-dom/client"; log("web clipper script loaded"); -let root = null; +let root: ReturnType | null = null; // Store last snippet for current page (page-specific, not persisted) let lastSnippetPage: PageModel | null = null; +// Cache the parser type setting +let cachedParserType: ContentParserType = "readability"; +// Preview root element reference +let previewRootEl: HTMLDivElement | null = null; + +// Load parser setting on script load +readSyncStorageSettings().then((settings) => { + cachedParserType = settings.contentParser; +}); + +// Listen for storage changes to update cached parser type +chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName === "sync" && changes.contentParser) { + cachedParserType = changes.contentParser.newValue; + } +}); chrome.runtime.onMessage.addListener(function (msg: Message, sender, sendResponse) { if (msg.type === "parse_doc") { - const webClipper = new WebClipper() + // Use parser type from message if provided, otherwise use cached setting + const parserType = msg.payload?.parserType || cachedParserType; + const webClipper = new WebClipper(parserType); const page = webClipper.parseDoc(document.cloneNode(true) as Document); - sendResponse({page}); + const isHuntlySite = webClipper.hasHuntlyMeta(document); + sendResponse({page, parserType, isHuntlySite}); return; } else if (msg.type === 'shortcuts_preview') { let page = msg.payload?.page; + // Use parserType from message if provided, otherwise use cached setting + const parserType = msg.payload?.parserType || cachedParserType; + log('[Huntly] shortcuts_preview - received parserType:', msg.payload?.parserType, 'using:', parserType); if (!page) { - const webClipper = new WebClipper(); + const webClipper = new WebClipper(parserType); page = webClipper.parseDoc(document.cloneNode(true) as Document); } const rootId = "huntly_preview_unique_root"; - let elRoot = document.getElementById(rootId); + let elRoot = document.getElementById(rootId) as HTMLDivElement | null; if (!elRoot) { - const elPreview = document.createElement("div"); - elPreview.setAttribute("id", rootId); - document.body.append(elPreview); - elRoot = elPreview; + elRoot = document.createElement("div"); + elRoot.id = rootId; + document.body.append(elRoot); } - if (elRoot.getAttribute("data-preview") !== "1") { - elRoot.setAttribute("data-preview", "1"); - if (!root) { - root = createRoot(elRoot); - } else { + + // Save original scroll state - don't modify it, just track for cleanup + const originalBodyOverflow = document.body.style.overflow; + const originalHtmlOverflow = document.documentElement.style.overflow; + + // Close handler to clean up the preview + const handleClose = () => { + if (previewRootEl) { + delete previewRootEl.dataset.preview; + } + if (root) { root.unmount(); - root = createRoot(elRoot); + root = null; } - root.render( -
- ); + // Restore original scroll state + document.body.style.overflow = originalBodyOverflow; + document.documentElement.style.overflow = originalHtmlOverflow; + }; + + // Always re-render (unmount first if already mounted) + if (root) { + root.unmount(); } + + elRoot.dataset.preview = "1"; + previewRootEl = elRoot; + root = createRoot(elRoot); + root.render( + + ); return; } else if (msg.type === "get_selection") { const webClipper = new WebClipper(); @@ -124,12 +172,17 @@ chrome.runtime.onMessage.addListener(function (msg: Message, sender, sendRespons function timeoutSavePureRead() { setTimeout(() => { - const webClipper = new WebClipper() + const webClipper = new WebClipper(cachedParserType); webClipper.autoSavePureRead(); }, 2000); } export class WebClipper { + private parserType: ContentParserType; + + constructor(parserType: ContentParserType = "readability") { + this.parserType = parserType; + } autoSavePureRead() { if (!this.isMaybeReadable()) { @@ -139,9 +192,9 @@ export class WebClipper { this.savePureRead(true); } - hasHuntlyMeta(doc) { + hasHuntlyMeta(doc): boolean { // exclude huntly web app - return doc.querySelector("meta[data-huntly='1']"); + return !!doc.querySelector("meta[data-huntly='1']"); } isMaybeReadable() { @@ -167,13 +220,9 @@ export class WebClipper { } parseDoc(doc: Document): PageModel { - if (this.hasHuntlyMeta(doc)) { - return null; - } - const baseURI = getBaseURI(doc); const documentURI = doc.documentURI; - const article = new Readability(doc, {debug: false}).parse(); + const article = parseDocument(doc, this.parserType); const ogImage = doc.querySelector("meta[property='og:image']"); let thumbUrl = ogImage ? ogImage.getAttribute("content") : null; diff --git a/app/extension/webpack/webpack.common.js b/app/extension/webpack/webpack.common.js index ee7b7379..61ebcafe 100644 --- a/app/extension/webpack/webpack.common.js +++ b/app/extension/webpack/webpack.common.js @@ -11,6 +11,9 @@ if (process.env.BROWSER === 'firefox') { manifestFile = 'public/manifest-firefox.json'; } +// Get version from environment variable (without 'v' prefix) +const extensionVersion = process.env.EXTENSION_VERSION; + module.exports = { entry: { popup: path.join(srcDir, 'popup.tsx'), @@ -34,8 +37,14 @@ module.exports = { }, module: { rules: [ + // CSS as raw string for Shadow DOM injection (use ?raw query) + { + resourceQuery: /raw/, + type: 'asset/source', + }, { test: /\.module\.css$/i, + resourceQuery: { not: [/raw/] }, use: [ {loader: "style-loader"}, { @@ -52,6 +61,7 @@ module.exports = { { test: /\.css$/i, exclude: /\.module\.css$/i, + resourceQuery: { not: [/raw/] }, use: [ {loader: "style-loader"}, { @@ -79,7 +89,19 @@ module.exports = { options: {}, }), new CopyPlugin({ - patterns: [{ from: manifestFile, to: path.resolve(__dirname, '..', outputPath, 'manifest.json') }] + patterns: [{ + from: manifestFile, + to: path.resolve(__dirname, '..', outputPath, 'manifest.json'), + // Only apply transform when EXTENSION_VERSION is set (during release builds) + // This avoids unnecessary re-compilation during development + ...(extensionVersion ? { + transform(content) { + const manifest = JSON.parse(content.toString()); + manifest.version = extensionVersion; + return JSON.stringify(manifest, null, 2); + } + } : {}) + }] }) ], }; diff --git a/app/extension/yarn.lock b/app/extension/yarn.lock index 54deb469..f6403e15 100644 --- a/app/extension/yarn.lock +++ b/app/extension/yarn.lock @@ -2,6 +2,116 @@ # yarn lockfile v1 +"@ai-sdk/anthropic@^1.2.12": + version "1.2.12" + resolved "https://registry.npmmirror.com/@ai-sdk/anthropic/-/anthropic-1.2.12.tgz#80a4b2527c6bb120778fbc83da4af775aae953a5" + integrity sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ== + dependencies: + "@ai-sdk/provider" "1.1.3" + "@ai-sdk/provider-utils" "2.2.8" + +"@ai-sdk/azure@^1.3.24": + version "1.3.25" + resolved "https://registry.npmmirror.com/@ai-sdk/azure/-/azure-1.3.25.tgz#6cb113b95b4d02ea8cf0eb23a6393ee5e9ec09e6" + integrity sha512-cTME89A9UYrza0t5pbY9b80yYY02Q5ALQdB2WP3R7/Yl1PLwbFChx994Q3Un0G2XV5h3arlm4fZTViY10isjhQ== + dependencies: + "@ai-sdk/openai" "1.3.24" + "@ai-sdk/provider" "1.1.3" + "@ai-sdk/provider-utils" "2.2.8" + +"@ai-sdk/deepseek@^0.1.8": + version "0.1.17" + resolved "https://registry.npmmirror.com/@ai-sdk/deepseek/-/deepseek-0.1.17.tgz#f76155ee7018f1218bc7e2d06126aac474ad4410" + integrity sha512-UGVPYJSgV8Z4mbEUhqh/uRM2mBUS5VoWA3MUN+gg4xPjh3IvwQwVHAYWNzEhmlTbySIv3DE6x7oTyuS73gHGbg== + dependencies: + "@ai-sdk/openai-compatible" "0.1.17" + "@ai-sdk/provider" "1.0.12" + "@ai-sdk/provider-utils" "2.1.15" + +"@ai-sdk/google@^1.2.20": + version "1.2.22" + resolved "https://registry.npmmirror.com/@ai-sdk/google/-/google-1.2.22.tgz#9993e4781c9a773cd17d47490b9efdc90895abd2" + integrity sha512-Ppxu3DIieF1G9pyQ5O1Z646GYR0gkC57YdBqXJ82qvCdhEhZHu0TWhmnOoeIWe2olSbuDeoOY+MfJrW8dzS3Hw== + dependencies: + "@ai-sdk/provider" "1.1.3" + "@ai-sdk/provider-utils" "2.2.8" + +"@ai-sdk/groq@^1.1.14": + version "1.2.9" + resolved "https://registry.npmmirror.com/@ai-sdk/groq/-/groq-1.2.9.tgz#e3987bae374a714ab9dd3589bbf5e1e13e5f5751" + integrity sha512-7MoDaxm8yWtiRbD1LipYZG0kBl+Xe0sv/EeyxnHnGPZappXdlgtdOgTZVjjXkT3nWP30jjZi9A45zoVrBMb3Xg== + dependencies: + "@ai-sdk/provider" "1.1.3" + "@ai-sdk/provider-utils" "2.2.8" + +"@ai-sdk/openai-compatible@0.1.17": + version "0.1.17" + resolved "https://registry.npmmirror.com/@ai-sdk/openai-compatible/-/openai-compatible-0.1.17.tgz#1d85158879ead78ad5e126d0dc62f2297a18b143" + integrity sha512-e60+yxQ29e8RlsTWBW4kLuQJMpVJzH5+cpOeUXLXU6M9wc8BOQCyYg4jYh2ldnfvYCKXYxb2kYeLW7L9fqhhMw== + dependencies: + "@ai-sdk/provider" "1.0.12" + "@ai-sdk/provider-utils" "2.1.15" + +"@ai-sdk/openai@1.3.24", "@ai-sdk/openai@^1.3.22": + version "1.3.24" + resolved "https://registry.npmmirror.com/@ai-sdk/openai/-/openai-1.3.24.tgz#169b78a1ccf338e5dbd8696a55f57d3ca2e3d6bc" + integrity sha512-GYXnGJTHRTZc4gJMSmFRgEQudjqd4PUN0ZjQhPwOAYH1yOAvQoG/Ikqs+HyISRbLPCrhbZnPKCNHuRU4OfpW0Q== + dependencies: + "@ai-sdk/provider" "1.1.3" + "@ai-sdk/provider-utils" "2.2.8" + +"@ai-sdk/provider-utils@2.1.15": + version "2.1.15" + resolved "https://registry.npmmirror.com/@ai-sdk/provider-utils/-/provider-utils-2.1.15.tgz#20d23dfada7d988bebf176fdfc62b48fca7bd822" + integrity sha512-ndMVtDm2xS86t45CJZSfyl7UblZFewRB8gZkXQHeNi7BhjCYkhE+XQMwfDl6UOAO7kaV60IC1R4JLDWxWiiHug== + dependencies: + "@ai-sdk/provider" "1.0.12" + eventsource-parser "^3.0.0" + nanoid "^3.3.8" + secure-json-parse "^2.7.0" + +"@ai-sdk/provider-utils@2.2.8", "@ai-sdk/provider-utils@^2.0.0": + version "2.2.8" + resolved "https://registry.npmmirror.com/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz#ad11b92d5a1763ab34ba7b5fc42494bfe08b76d1" + integrity sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA== + dependencies: + "@ai-sdk/provider" "1.1.3" + nanoid "^3.3.8" + secure-json-parse "^2.7.0" + +"@ai-sdk/provider@1.0.12": + version "1.0.12" + resolved "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-1.0.12.tgz#f2df2b1f17863e213b5138d27cd5d09bcb6f976c" + integrity sha512-88Uu1zJIE1UUOVJWfE2ybJXgiH8JJ97QY9fbmplErEbfa/k/1kF+tWMVAAJolF2aOGmazQGyQLhv4I9CCuVACw== + dependencies: + json-schema "^0.4.0" + +"@ai-sdk/provider@1.1.3", "@ai-sdk/provider@^1.0.0": + version "1.1.3" + resolved "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-1.1.3.tgz#ebdda8077b8d2b3f290dcba32c45ad19b2704681" + integrity sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg== + dependencies: + json-schema "^0.4.0" + +"@ai-sdk/react@1.2.12": + version "1.2.12" + resolved "https://registry.npmmirror.com/@ai-sdk/react/-/react-1.2.12.tgz#f4250b6df566b170af98a71d5708b52108dd0ce1" + integrity sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g== + dependencies: + "@ai-sdk/provider-utils" "2.2.8" + "@ai-sdk/ui-utils" "1.2.11" + swr "^2.2.5" + throttleit "2.1.0" + +"@ai-sdk/ui-utils@1.2.11": + version "1.2.11" + resolved "https://registry.npmmirror.com/@ai-sdk/ui-utils/-/ui-utils-1.2.11.tgz#4f815589d08d8fef7292ade54ee5db5d09652603" + integrity sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w== + dependencies: + "@ai-sdk/provider" "1.1.3" + "@ai-sdk/provider-utils" "2.2.8" + zod-to-json-schema "^3.24.1" + "@alloc/quick-lru@^5.2.0": version "5.2.0" resolved "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz" @@ -27,7 +137,7 @@ resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.21.0.tgz" integrity sha512-gMuZsmsgxk/ENC3O/fRw5QY8A9/uxQbbCEypnLIiYYc/qVJtEV7ouxC3EllIIwNzMqAQee5tanFabWsUOutS7g== -"@babel/core@^7.0.0", "@babel/core@^7.0.0-0", "@babel/core@^7.1.0", "@babel/core@^7.12.3", "@babel/core@^7.7.2", "@babel/core@^7.8.0", "@babel/core@>=7.0.0-beta.0 <8": +"@babel/core@^7.1.0", "@babel/core@^7.12.3", "@babel/core@^7.7.2", "@babel/core@^7.8.0": version "7.21.3" resolved "https://registry.npmjs.org/@babel/core/-/core-7.21.3.tgz" integrity sha512-qIJONzoa/qiHghnm0l1n4i/6IIziDpzqc36FBs4pzMhDUraHqponwJLiAKm1hGLP3OSB/TVNz6rMwVGpwxxySw== @@ -533,7 +643,7 @@ resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.0.tgz" integrity sha512-G/YwXTkv7Den9mXDO7AhLWkE3q+I92B+VqAE+dYG4NGPaHZGvt3G8Q0p9vmE+sq7rTGphUbAvmQ9YpbfMQGGlA== -"@emotion/react@^11.0.0-rc.0", "@emotion/react@^11.10.6", "@emotion/react@^11.4.1", "@emotion/react@^11.5.0": +"@emotion/react@^11.10.6": version "11.10.6" resolved "https://registry.npmjs.org/@emotion/react/-/react-11.10.6.tgz" integrity sha512-6HT8jBmcSkfzO7mc+N1L9uwvOnlcGoix8Zn7srt+9ga0MjREo6lRpuVX0kzo6Jp6oTqDhREOFsygN6Ew4fEQbw== @@ -563,7 +673,7 @@ resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.1.tgz" integrity sha512-zxRBwl93sHMsOj4zs+OslQKg/uhF38MB+OMKoCrVuS0nyTkqnau+BM3WGEoOptg9Oz45T/aIGs1qbVAsEFo3nA== -"@emotion/styled@^11.10.6", "@emotion/styled@^11.3.0": +"@emotion/styled@^11.10.6": version "11.10.6" resolved "https://registry.npmjs.org/@emotion/styled/-/styled-11.10.6.tgz" integrity sha512-OXtBzOmDSJo5Q0AFemHCfl+bUueT8BIcPSxu0EGTpGk6DmI5dnhSzQANm1e1ze0YZL7TDyAyy6s/b/zmGOS3Og== @@ -815,7 +925,7 @@ "@jridgewell/gen-mapping" "^0.3.0" "@jridgewell/trace-mapping" "^0.3.9" -"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@1.4.14": +"@jridgewell/sourcemap-codec@1.4.14", "@jridgewell/sourcemap-codec@^1.4.10": version "1.4.14" resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz" integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== @@ -828,6 +938,11 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" +"@mixmark-io/domino@^2.2.0": + version "2.2.0" + resolved "https://registry.npmmirror.com/@mixmark-io/domino/-/domino-2.2.0.tgz#4e8ec69bf1afeb7a14f0628b7e2c0f35bdb336c3" + integrity sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw== + "@mozilla/readability@^0.4.2": version "0.4.3" resolved "https://registry.npmjs.org/@mozilla/readability/-/readability-0.4.3.tgz" @@ -859,7 +974,7 @@ dependencies: "@babel/runtime" "^7.21.0" -"@mui/material@^5.0.0", "@mui/material@^5.11.14": +"@mui/material@^5.11.14": version "5.11.14" resolved "https://registry.npmjs.org/@mui/material/-/material-5.11.14.tgz" integrity sha512-uoiUyybmo+M+nYARBygmbXgX6s/hH0NKD56LCAv9XvmdGVoXhEGjOvxI5/Bng6FS3NNybnA8V+rgZW1Z/9OJtA== @@ -934,7 +1049,7 @@ "@nodelib/fs.stat" "2.0.5" run-parallel "^1.1.9" -"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5": +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": version "2.0.5" resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== @@ -947,6 +1062,11 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@opentelemetry/api@1.9.0": + version "1.9.0" + resolved "https://registry.npmmirror.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" + integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== + "@popperjs/core@^2.11.6": version "2.11.6" resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz" @@ -1019,6 +1139,11 @@ dependencies: "@types/ms" "*" +"@types/diff-match-patch@^1.0.36": + version "1.0.36" + resolved "https://registry.npmmirror.com/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz#dcef10a69d357fe9d43ac4ff2eca6b85dbf466af" + integrity sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg== + "@types/eslint-scope@^3.7.3": version "3.7.4" resolved "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz" @@ -1102,7 +1227,7 @@ dependencies: "@types/istanbul-lib-report" "*" -"@types/jest@^27.0.0", "@types/jest@^27.0.2": +"@types/jest@^27.0.2": version "27.5.2" resolved "https://registry.npmjs.org/@types/jest/-/jest-27.5.2.tgz" integrity sha512-mpT8LJJ4CMeeahobofYWIjFo0xonRS/HfxnVEPMPFSQdGUt1uHCnoPT7Zhb+sjDU2wz0oKV0OLUR0WzrHNgfeA== @@ -1175,7 +1300,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^17.0.0 || ^18.0.0", "@types/react@^18.0.0", "@types/react@>=18": +"@types/react@*", "@types/react@^18.0.0": version "18.0.29" resolved "https://registry.npmjs.org/@types/react/-/react-18.0.29.tgz" integrity sha512-wXHktgUABxplw1+UnljseDq4+uztQyp2tlWZRIxHlpchsCFqiYkvaDS8JR7eKOQm8wziTH/el5qL7D6gYNkYcw== @@ -1194,6 +1319,11 @@ resolved "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz" integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== +"@types/turndown@^5.0.6": + version "5.0.6" + resolved "https://registry.npmmirror.com/@types/turndown/-/turndown-5.0.6.tgz#42a27397298a312d6088f29c0ff4819c518c1ecb" + integrity sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg== + "@types/unist@*", "@types/unist@^3.0.0": version "3.0.3" resolved "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz" @@ -1359,6 +1489,11 @@ resolved "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.7.0.tgz" integrity sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q== +"@xmldom/xmldom@^0.8.10": + version "0.8.11" + resolved "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.11.tgz#b79de2d67389734c57c52595f7a7305e30c2d608" + integrity sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw== + "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz" @@ -1397,7 +1532,7 @@ acorn@^7.1.1: resolved "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8, acorn@^8.2.4, acorn@^8.5.0, acorn@^8.7.1: +acorn@^8.2.4, acorn@^8.5.0, acorn@^8.7.1: version "8.8.2" resolved "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz" integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== @@ -1409,12 +1544,24 @@ agent-base@6: dependencies: debug "4" +ai@^4.3.16: + version "4.3.19" + resolved "https://registry.npmmirror.com/ai/-/ai-4.3.19.tgz#e94f5b37f3885bc9c9637f892e13bddd0a1857e5" + integrity sha512-dIE2bfNpqHN3r6IINp9znguYdhIOheKW2LDigAMrgt/upT3B8eBGPSCblENvaZGoq+hxaN9fSMzjWpbqloP+7Q== + dependencies: + "@ai-sdk/provider" "1.1.3" + "@ai-sdk/provider-utils" "2.2.8" + "@ai-sdk/react" "1.2.12" + "@ai-sdk/ui-utils" "1.2.11" + "@opentelemetry/api" "1.9.0" + jsondiffpatch "0.6.0" + ajv-keywords@^3.5.2: version "3.5.2" resolved "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz" integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== -ajv@^6.12.5, ajv@^6.9.1: +ajv@^6.12.5: version "6.12.6" resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== @@ -1507,7 +1654,7 @@ autoprefixer@^10.4.13, autoprefixer@^10.4.14: picocolors "^1.0.0" postcss-value-parser "^4.2.0" -babel-jest@^27.5.1, "babel-jest@>=27.0.0 <28": +babel-jest@^27.5.1: version "27.5.1" resolved "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz" integrity sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg== @@ -1617,7 +1764,7 @@ browser-process-hrtime@^1.0.0: resolved "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz" integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow== -browserslist@^4.14.5, browserslist@^4.21.3, browserslist@^4.21.4, browserslist@^4.21.5, "browserslist@>= 4.21.0": +browserslist@^4.14.5, browserslist@^4.21.3, browserslist@^4.21.4, browserslist@^4.21.5: version "4.21.5" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.21.5.tgz" integrity sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w== @@ -1693,6 +1840,11 @@ chalk@^4.0.0, chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@^5.3.0: + version "5.6.2" + resolved "https://registry.npmmirror.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea" + integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== + char-regex@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz" @@ -1795,16 +1947,16 @@ color-convert@^2.0.1: dependencies: color-name "~1.1.4" -color-name@~1.1.4: - version "1.1.4" - resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" - integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== - color-name@1.1.3: version "1.1.3" resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + colorette@^2.0.14: version "2.0.19" resolved "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz" @@ -1977,7 +2129,7 @@ data-urls@^2.0.0: whatwg-mimetype "^2.3.0" whatwg-url "^8.0.0" -debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@4: +debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1: version "4.3.4" resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -2016,12 +2168,21 @@ deepmerge@^4.2.2: resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== +defuddle@^0.6.6: + version "0.6.6" + resolved "https://registry.npmmirror.com/defuddle/-/defuddle-0.6.6.tgz#0ac055e2e1bef0cf3244968270bed04c653e6de9" + integrity sha512-cexePkdZCwg8g1DHCV3xfE6DGTBeldtJct4/fOumYE/kx+sgoDg8yxjCxlC/Pss0v11G5CUFSUmf7fGJg249AA== + optionalDependencies: + mathml-to-latex "^1.4.3" + temml "^0.11.2" + turndown "^7.2.0" + delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== -dequal@^2.0.0: +dequal@^2.0.0, dequal@^2.0.3: version "2.0.3" resolved "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== @@ -2043,6 +2204,11 @@ didyoumean@^1.2.2: resolved "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz" integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw== +diff-match-patch@^1.0.5: + version "1.0.5" + resolved "https://registry.npmmirror.com/diff-match-patch/-/diff-match-patch-1.0.5.tgz#abb584d5f10cd1196dfc55aa03701592ae3f7b37" + integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw== + diff-sequences@^27.5.1: version "27.5.1" resolved "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz" @@ -2218,6 +2384,11 @@ events@^3.2.0: resolved "https://registry.npmjs.org/events/-/events-3.3.0.tgz" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== +eventsource-parser@^3.0.0: + version "3.0.6" + resolved "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.0.6.tgz#292e165e34cacbc936c3c92719ef326d4aeb4e90" + integrity sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg== + execa@^5.0.0: version "5.1.1" resolved "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz" @@ -2274,7 +2445,7 @@ fast-glob@^3.2.12, fast-glob@^3.2.7, fast-glob@^3.2.9: merge2 "^1.3.0" micromatch "^4.0.4" -fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@2.x: +fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== @@ -2392,14 +2563,7 @@ glob-parent@^5.1.2, glob-parent@~5.1.2: dependencies: is-glob "^4.0.1" -glob-parent@^6.0.1: - version "6.0.2" - resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz" - integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== - dependencies: - is-glob "^4.0.3" - -glob-parent@^6.0.2: +glob-parent@^6.0.1, glob-parent@^6.0.2: version "6.0.2" resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz" integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== @@ -2411,27 +2575,27 @@ glob-to-regexp@^0.4.1: resolved "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: - version "7.2.3" - resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz" - integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== +glob@7.1.6: + version "7.1.6" + resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz" + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== dependencies: fs.realpath "^1.0.0" inflight "^1.0.4" inherits "2" - minimatch "^3.1.1" + minimatch "^3.0.4" once "^1.3.0" path-is-absolute "^1.0.0" -glob@7.1.6: - version "7.1.6" - resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz" - integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== +glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: + version "7.2.3" + resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== dependencies: fs.realpath "^1.0.0" inflight "^1.0.4" inherits "2" - minimatch "^3.0.4" + minimatch "^3.1.1" once "^1.3.0" path-is-absolute "^1.0.0" @@ -2594,7 +2758,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@~2.0.3, inherits@2: +inherits@2, inherits@~2.0.3: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -3012,7 +3176,7 @@ jest-resolve-dependencies@^27.5.1: jest-regex-util "^27.5.1" jest-snapshot "^27.5.1" -jest-resolve@*, jest-resolve@^27.5.1: +jest-resolve@^27.5.1: version "27.5.1" resolved "https://registry.npmjs.org/jest-resolve/-/jest-resolve-27.5.1.tgz" integrity sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw== @@ -3165,7 +3329,7 @@ jest-worker@^27.4.5, jest-worker@^27.5.1: merge-stream "^2.0.0" supports-color "^8.0.0" -jest@^27.0.0, jest@^27.2.1: +jest@^27.2.1: version "27.5.1" resolved "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz" integrity sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ== @@ -3247,11 +3411,25 @@ json-schema-traverse@^0.4.1: resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== -json5@^2.1.2, json5@^2.2.2, json5@2.x: +json-schema@^0.4.0: + version "0.4.0" + resolved "https://registry.npmmirror.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" + integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== + +json5@2.x, json5@^2.1.2, json5@^2.2.2: version "2.2.3" resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== +jsondiffpatch@0.6.0: + version "0.6.0" + resolved "https://registry.npmmirror.com/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz#daa6a25bedf0830974c81545568d5f671c82551f" + integrity sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ== + dependencies: + "@types/diff-match-patch" "^1.0.36" + chalk "^5.3.0" + diff-match-patch "^1.0.5" + kind-of@^6.0.2: version "6.0.3" resolved "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz" @@ -3376,6 +3554,13 @@ markdown-table@^3.0.0: resolved "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz" integrity sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw== +mathml-to-latex@^1.4.3: + version "1.5.0" + resolved "https://registry.npmmirror.com/mathml-to-latex/-/mathml-to-latex-1.5.0.tgz#b10e49d537ef1f974dd7e6e3bc3dc8a089c27b48" + integrity sha512-rrWn0eEvcEcdMM4xfHcSGIy+i01DX9byOdXTLWg+w1iJ6O6ohP5UXY1dVzNUZLhzfl3EGcRekWLhY7JT5Omaew== + dependencies: + "@xmldom/xmldom" "^0.8.10" + mdast-util-find-and-replace@^3.0.0: version "3.0.2" resolved "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz" @@ -3903,6 +4088,11 @@ nanoid@^3.3.6: resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz" integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== +nanoid@^3.3.8: + version "3.3.11" + resolved "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" @@ -3955,6 +4145,15 @@ object-hash@^3.0.0: resolved "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz" integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw== +ollama-ai-provider@^1.2.0: + version "1.2.0" + resolved "https://registry.npmmirror.com/ollama-ai-provider/-/ollama-ai-provider-1.2.0.tgz#b052c8ad96ef8048185a7ac01ee9351a0cedf0ce" + integrity sha512-jTNFruwe3O/ruJeppI/quoOUxG7NA6blG3ZyQj3lei4+NnJo7bi3eIRWqlVpRlu/mbzbFXeJSBuYQWF6pzGKww== + dependencies: + "@ai-sdk/provider" "^1.0.0" + "@ai-sdk/provider-utils" "^2.0.0" + partial-json "0.1.7" + once@^1.3.0: version "1.4.0" resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz" @@ -4035,6 +4234,11 @@ parse5@6.0.1: resolved "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== +partial-json@0.1.7: + version "0.1.7" + resolved "https://registry.npmmirror.com/partial-json/-/partial-json-0.1.7.tgz#b735a89edb3e25f231a3c4caeaae71dc9f578605" + integrity sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA== + path-exists@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz" @@ -4423,7 +4627,7 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0, postcss-value-parser@^ resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -"postcss@^7.0.0 || ^8.0.1", postcss@^8, postcss@^8.0.0, postcss@^8.0.3, postcss@^8.1.0, postcss@^8.2, postcss@^8.4, postcss@^8.4.19, postcss@^8.4.21, postcss@^8.4.6: +postcss@^8.4.19, postcss@^8.4.21: version "8.4.21" resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz" integrity sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg== @@ -4432,7 +4636,7 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0, postcss-value-parser@^ picocolors "^1.0.0" source-map-js "^1.0.2" -postcss@^8.2.14, postcss@^8.4.23, postcss@>=8.0.9: +postcss@^8.4.23: version "8.4.23" resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.23.tgz" integrity sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA== @@ -4524,7 +4728,7 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" -"react-dom@^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18", "react-dom@^17.0.0 || ^18.0.0", react-dom@^18.2.0, react-dom@>=16.6.0: +react-dom@^18.2.0: version "18.2.0" resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz" integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== @@ -4537,12 +4741,7 @@ react-fast-compare@^2.0.1: resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz" integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== -react-is@^16.13.1: - version "16.13.1" - resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" - integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== - -react-is@^16.7.0: +react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -4599,7 +4798,7 @@ react-transition-group@^4.4.5: loose-envify "^1.4.0" prop-types "^15.6.2" -"react@^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18", "react@^17.0.0 || ^18.0.0", react@^18.2.0, react@>=16.6.0, react@>=16.8.0, react@>=18: +react@^18.2.0: version "18.2.0" resolved "https://registry.npmjs.org/react/-/react-18.2.0.tgz" integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== @@ -4794,23 +4993,23 @@ schema-utils@^3.1.0, schema-utils@^3.1.1: ajv "^6.12.5" ajv-keywords "^3.5.2" -semver@^6.0.0: - version "6.3.0" - resolved "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz" - integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== - -semver@^6.3.0: - version "6.3.0" - resolved "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz" - integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== +secure-json-parse@^2.7.0: + version "2.7.0" + resolved "https://registry.npmmirror.com/secure-json-parse/-/secure-json-parse-2.7.0.tgz#5a5f9cd6ae47df23dba3151edd06855d47e09862" + integrity sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw== -semver@^7.3.2, semver@^7.3.4, semver@^7.3.8, semver@7.x: +semver@7.x, semver@^7.3.2, semver@^7.3.4, semver@^7.3.8: version "7.3.8" resolved "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz" integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A== dependencies: lru-cache "^6.0.0" +semver@^6.0.0, semver@^6.3.0: + version "6.3.0" + resolved "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz" + integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== + serialize-javascript@^6.0.0, serialize-javascript@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz" @@ -4897,13 +5096,6 @@ stack-utils@^2.0.3: dependencies: escape-string-regexp "^2.0.0" -string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== - dependencies: - safe-buffer "~5.1.0" - string-length@^4.0.1: version "4.0.2" resolved "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz" @@ -4921,6 +5113,13 @@ string-width@^4.1.0, string-width@^4.2.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + stringify-entities@^4.0.0: version "4.0.4" resolved "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz" @@ -5022,6 +5221,14 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +swr@^2.2.5: + version "2.3.8" + resolved "https://registry.npmmirror.com/swr/-/swr-2.3.8.tgz#aa15596321a34e575226a60576bade0b57adf7bf" + integrity sha512-gaCPRVoMq8WGDcWj9p4YWzCMPHzE0WNl6W8ADIx9c3JBEIdMkJGMzW+uzXvxHMltwcYACr9jP+32H8/hgwMR7w== + dependencies: + dequal "^2.0.3" + use-sync-external-store "^1.6.0" + symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz" @@ -5066,6 +5273,11 @@ tapable@^2.1.1, tapable@^2.2.0: resolved "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz" integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== +temml@^0.11.2: + version "0.11.11" + resolved "https://registry.npmmirror.com/temml/-/temml-0.11.11.tgz#f49e738c8416acfa98267173711ae47f1ed371fc" + integrity sha512-Z/Ihgwad+ges0ez6+KmKWZ3o4BYbP6aZ/cU94cVtN+DwxwqxjHgcF4Z6cb9jLkKN+aU7uni165HsIxLHs5/TqA== + terminal-link@^2.0.0: version "2.1.1" resolved "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz" @@ -5123,6 +5335,11 @@ throat@^6.0.1: resolved "https://registry.npmjs.org/throat/-/throat-6.0.2.tgz" integrity sha512-WKexMoJj3vEuK0yFEapj8y64V0A6xcuPuK9Gt1d0R+dzCSJc0lHqQytAbSB4cDAK0dWh4T0E2ETkoLE2WZ41OQ== +throttleit@2.1.0: + version "2.1.0" + resolved "https://registry.npmmirror.com/throttleit/-/throttleit-2.1.0.tgz#a7e4aa0bf4845a5bd10daa39ea0c783f631a07b4" + integrity sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw== + tiny-case@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz" @@ -5217,6 +5434,13 @@ tslib@^1.10.0: resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +turndown@^7.2.0, turndown@^7.2.2: + version "7.2.2" + resolved "https://registry.npmmirror.com/turndown/-/turndown-7.2.2.tgz#9557642b54046c5912b3d433f34dd588de455a43" + integrity sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ== + dependencies: + "@mixmark-io/domino" "^2.2.0" + type-check@~0.3.2: version "0.3.2" resolved "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz" @@ -5246,7 +5470,7 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" -typescript@*, "typescript@^4.4.3 ", "typescript@>=3.8 <5.0": +"typescript@^4.4.3 ": version "4.9.5" resolved "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== @@ -5330,6 +5554,11 @@ url-parse@^1.5.3: querystringify "^2.1.1" requires-port "^1.0.0" +use-sync-external-store@^1.6.0: + version "1.6.0" + resolved "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" + integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== + util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" @@ -5406,7 +5635,7 @@ webidl-conversions@^6.1.0: resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz" integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== -webpack-cli@^4.0.0, webpack-cli@4.x.x: +webpack-cli@^4.0.0: version "4.10.0" resolved "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz" integrity sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w== @@ -5437,7 +5666,7 @@ webpack-sources@^3.2.3: resolved "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz" integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== -webpack@*, webpack@^5.0.0, webpack@^5.1.0, "webpack@4.x.x || 5.x.x": +webpack@^5.0.0: version "5.76.3" resolved "https://registry.npmjs.org/webpack/-/webpack-5.76.3.tgz" integrity sha512-18Qv7uGPU8b2vqGeEEObnfICyw2g39CHlDEK4I7NK13LOur1d0HGmGNKGT58Eluwddpn3oEejwvBPoP4M7/KSA== @@ -5569,7 +5798,7 @@ yaml@^2.1.1: resolved "https://registry.npmjs.org/yaml/-/yaml-2.2.2.tgz" integrity sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA== -yargs-parser@^20.2.2, yargs-parser@20.x: +yargs-parser@20.x, yargs-parser@^20.2.2: version "20.2.9" resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz" integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== @@ -5597,6 +5826,16 @@ yup@^1.0.2: toposort "^2.0.2" type-fest "^2.19.0" +zod-to-json-schema@^3.24.1: + version "3.25.1" + resolved "https://registry.npmmirror.com/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz#7f24962101a439ddade2bf1aeab3c3bfec7d84ba" + integrity sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA== + +zod@^3.23.8: + version "3.25.76" + resolved "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== + zwitch@^2.0.0: version "2.0.4" resolved "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz" diff --git a/app/server/huntly-server/src/main/java/com/huntly/server/controller/TweetController.java b/app/server/huntly-server/src/main/java/com/huntly/server/controller/TweetController.java index daa1f446..fb2a48e7 100644 --- a/app/server/huntly-server/src/main/java/com/huntly/server/controller/TweetController.java +++ b/app/server/huntly-server/src/main/java/com/huntly/server/controller/TweetController.java @@ -8,6 +8,7 @@ import com.huntly.server.event.EventPublisher; import com.huntly.server.event.TweetPageCaptureEvent; import com.huntly.server.service.CapturePageService; +import com.huntly.server.service.GlobalSettingService; import com.huntly.server.service.TweetTrackService; import lombok.extern.slf4j.Slf4j; import org.springframework.validation.annotation.Validated; @@ -28,25 +29,29 @@ public class TweetController { private final CapturePageService capturePageService; - + private final TweetParser tweetParser; - + private final TweetTrackService tweetTrackService; - + private final EventPublisher eventPublisher; - public TweetController(CapturePageService capturePageService, TweetParser tweetParser, TweetTrackService tweetTrackService, EventPublisher eventPublisher) { + private final GlobalSettingService globalSettingService; + + public TweetController(CapturePageService capturePageService, TweetParser tweetParser, TweetTrackService tweetTrackService, EventPublisher eventPublisher, GlobalSettingService globalSettingService) { this.capturePageService = capturePageService; this.tweetParser = tweetParser; this.tweetTrackService = tweetTrackService; this.eventPublisher = eventPublisher; + this.globalSettingService = globalSettingService; } @PostMapping("/saveTweets") public ApiResult saveTweets(@RequestBody InterceptTweets tweets) { var parsedPages = tweetParser.tweetsToPages(tweets); AtomicInteger count = new AtomicInteger(); - Integer minLikes = tweets.getMinLikes(); + // Get minLikes from server-side GlobalSetting instead of extension request + int minLikes = globalSettingService.getAutoSaveTweetMinLikes(); parsedPages.forEach(parsedPage -> { // SQLite only supports one connection. To avoid other threads from being unable to obtain the SQLite connection, asynchronous events are used eventPublisher.publishTweetPageCaptureEvent(new TweetPageCaptureEvent(parsedPage, tweets.getLoginScreenName(), tweets.getBrowserScreenName(), minLikes)); diff --git a/app/server/huntly-server/src/main/java/com/huntly/server/domain/entity/GlobalSetting.java b/app/server/huntly-server/src/main/java/com/huntly/server/domain/entity/GlobalSetting.java index b6f74b1f..b88a8f3e 100644 --- a/app/server/huntly-server/src/main/java/com/huntly/server/domain/entity/GlobalSetting.java +++ b/app/server/huntly-server/src/main/java/com/huntly/server/domain/entity/GlobalSetting.java @@ -55,6 +55,9 @@ public class GlobalSetting implements Serializable { @Column(name = "mcp_token") private String mcpToken; + @Column(name = "auto_save_tweet_min_likes") + private Integer autoSaveTweetMinLikes; + @Column(name = "created_at") private Instant createdAt; diff --git a/app/server/huntly-server/src/main/java/com/huntly/server/service/GlobalSettingService.java b/app/server/huntly-server/src/main/java/com/huntly/server/service/GlobalSettingService.java index 97487bd4..e666bebf 100644 --- a/app/server/huntly-server/src/main/java/com/huntly/server/service/GlobalSettingService.java +++ b/app/server/huntly-server/src/main/java/com/huntly/server/service/GlobalSettingService.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Service; import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; /** * @author lcomplete @@ -16,6 +17,13 @@ public class GlobalSettingService { private final GlobalSettingRepository settingRepository; + /** + * Cache for autoSaveTweetMinLikes value to avoid frequent database reads. + * Uses AtomicReference to ensure thread-safety. + * null means cache is not initialized, Integer value is the cached setting. + */ + private final AtomicReference cachedAutoSaveTweetMinLikes = new AtomicReference<>(null); + public GlobalSettingService(GlobalSettingRepository settingRepository) { this.settingRepository = settingRepository; } @@ -36,6 +44,38 @@ public GlobalSetting getGlobalSetting() { return setting; } + /** + * Get the autoSaveTweetMinLikes setting with caching for performance. + * This method is called frequently from TweetController, so it uses an in-memory cache + * to avoid repeated database queries. + * + * @return the minLikes value, returns 0 if null or not set + */ + public int getAutoSaveTweetMinLikes() { + Integer cached = cachedAutoSaveTweetMinLikes.get(); + if (cached != null) { + return cached; + } + + // Double-check locking pattern using compareAndSet + GlobalSetting setting = settingRepository.findAll().stream().findFirst().orElse(null); + int minLikes = (setting != null && setting.getAutoSaveTweetMinLikes() != null) + ? setting.getAutoSaveTweetMinLikes() + : 0; + + // Only set if still null (another thread may have set it) + cachedAutoSaveTweetMinLikes.compareAndSet(null, minLikes); + return minLikes; + } + + /** + * Clear the cached autoSaveTweetMinLikes value. + * Should be called when the global setting is updated. + */ + private void clearAutoSaveTweetMinLikesCache() { + cachedAutoSaveTweetMinLikes.set(null); + } + private String getDefaultArticleSummaryPrompt() { return "你是一个专业的文章摘要生成助手,能够生成高质量的中文摘要。请按照以下要求生成摘要:\n" + "1. 包含文章的主要观点和关键信息\n" @@ -82,7 +122,12 @@ public GlobalSetting saveGlobalSetting(GlobalSetting globalSetting) { dbSetting.setOpenApiKey(globalSetting.getOpenApiKey()); } dbSetting.setMcpToken(globalSetting.getMcpToken()); + dbSetting.setAutoSaveTweetMinLikes(globalSetting.getAutoSaveTweetMinLikes()); dbSetting.setUpdatedAt(globalSetting.getUpdatedAt()); + + // Clear cache when setting is updated + clearAutoSaveTweetMinLikesCache(); + return settingRepository.save(dbSetting); } }