From a2d55f1383b40e8b23e67ae0b01e9c563312424d Mon Sep 17 00:00:00 2001 From: lcomplete Date: Thu, 29 Jan 2026 01:32:38 +0800 Subject: [PATCH 1/5] feat: add AI-powered web clipper and X settings to browser extension - Add AI content processing with streaming support for web clipper - Add X (Twitter) settings page for configuring auto-save behavior - Add separate extension release workflow for independent versioning - Remove extension build from main release workflow - Add new parser utilities and UI components for content extraction - Update extension manifest and dependencies for new features Co-Authored-By: Claude Opus 4.5 --- .github/workflows/extension-release.yml | 118 ++++ .github/workflows/release.yml | 32 +- app/client/src/App.tsx | 2 + app/client/src/api/api.ts | 467 +++++++++++++ .../Navigation/Settings/SettingsNav.tsx | 12 +- .../SettingModal/LibrarySetting.tsx | 12 +- .../src/components/SettingModal/XSetting.tsx | 116 ++++ app/client/src/pages/settings/SettingsX.tsx | 33 + app/extension/package.json | 14 +- app/extension/public/icon.png | Bin 852 -> 0 bytes app/extension/public/manifest.json | 5 +- app/extension/src/ai/providers.ts | 222 ++++++ app/extension/src/ai/storage.ts | 154 +++++ app/extension/src/ai/types.ts | 301 ++++++++ app/extension/src/background.ts | 291 +++++++- .../src/components/AIProviderDialog.tsx | 509 ++++++++++++++ .../src/components/AIProvidersSettings.tsx | 235 +++++++ app/extension/src/components/AIToolbar.tsx | 501 ++++++++++++++ .../src/components/ArticlePreview.tsx | 301 ++++++++ .../src/components/AutoSaveSettings.tsx | 119 ++++ .../src/components/ParserSettings.tsx | 109 +++ .../src/components/PromptsSettings.tsx | 478 +++++++++++++ .../src/components/ServerSettings.tsx | 649 ++++++++++++++++++ app/extension/src/components/SettingsPage.tsx | 107 +++ .../src/components/ShadowDomPreview.tsx | 164 +++++ app/extension/src/model.d.ts | 2 +- app/extension/src/options.css | 227 +++++- app/extension/src/options.tsx | 33 +- app/extension/src/parser/contentParser.ts | 68 ++ app/extension/src/popup.tsx | 381 +++++----- app/extension/src/services.ts | 21 +- app/extension/src/settings.tsx | 25 +- app/extension/src/storage.ts | 172 ++++- app/extension/src/styles/shadowDomStyles.ts | 346 ++++++++++ app/extension/src/web_clipper.tsx | 91 ++- app/extension/webpack/webpack.common.js | 24 +- app/extension/yarn.lock | 385 +++++++++-- .../server/domain/entity/GlobalSetting.java | 3 + .../server/service/GlobalSettingService.java | 1 + 39 files changed, 6364 insertions(+), 366 deletions(-) create mode 100644 .github/workflows/extension-release.yml create mode 100644 app/client/src/components/SettingModal/XSetting.tsx create mode 100644 app/client/src/pages/settings/SettingsX.tsx delete mode 100644 app/extension/public/icon.png create mode 100644 app/extension/src/ai/providers.ts create mode 100644 app/extension/src/ai/storage.ts create mode 100644 app/extension/src/ai/types.ts create mode 100644 app/extension/src/components/AIProviderDialog.tsx create mode 100644 app/extension/src/components/AIProvidersSettings.tsx create mode 100644 app/extension/src/components/AIToolbar.tsx create mode 100644 app/extension/src/components/ArticlePreview.tsx create mode 100644 app/extension/src/components/AutoSaveSettings.tsx create mode 100644 app/extension/src/components/ParserSettings.tsx create mode 100644 app/extension/src/components/PromptsSettings.tsx create mode 100644 app/extension/src/components/ServerSettings.tsx create mode 100644 app/extension/src/components/SettingsPage.tsx create mode 100644 app/extension/src/components/ShadowDomPreview.tsx create mode 100644 app/extension/src/parser/contentParser.ts create mode 100644 app/extension/src/styles/shadowDomStyles.ts diff --git a/.github/workflows/extension-release.yml b/.github/workflows/extension-release.yml new file mode 100644 index 00000000..281f1d80 --- /dev/null +++ b/.github/workflows/extension-release.yml @@ -0,0 +1,118 @@ +name: huntly extension release workflow + +on: + push: + tags: [ 'ext/v[0-9]+.[0-9]+.[0-9]+*' ] + +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..8c1743b1 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[0-9]+.[0-9]+.[0-9]+*' ] 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/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..c7861e04 --- /dev/null +++ b/app/client/src/components/SettingModal/XSetting.tsx @@ -0,0 +1,116 @@ +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: 180 }} + /> + + + Only save tweets with at least this many likes. Set to 0 to save all tweets. + This filter applies globally and does not affect user-specific 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 60b97abe0160467fdbd277043107c6982b0f4a3c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 852 zcmV-a1FQUrP)e$b*>KOl!^;0krovql!`@6&N*)|(Q+!&%s#}*@FNW&zxpyYof~h|*UY@< zj5Cf79XPA|zx;pq-1F|e&t&>v!hHGuQZ8^G6O_*cKIVd28BiY=*u@1kGJz#rC?|M^ zvN58@7O=140ZXpK}p&08x1U`(nmTw4S)wrAqtO(cG z7lL{@ICf6koLlW{8Bj|k%)Kh{hY30q)O>Z0YY_*ki=_F#zQ7R+P)gv{1D4H(xt4%E zW^k-CHcxk?`cFu`ULywvhYj;gX``n5-1o~+Q}n#u0D?;O?#0FP8PHpQaiY~J_vE`| zZELS|yWLuzDruBkE>&o{-FZ*so2~(Ed~|F~9>4oaCSR#EtVCQoCHwfB0S^Q!UAFVR z55T4jMW-oz)~1bjfBPM*AIkmqo|HfnGO^hQz({k;)vArFTN32c9A0m8F7uct!Gpyd zXlO10#vzpblg+m+n0Ph-xWF4800v50Gys^us&N2;XF~<3Y5A(luKvLg0qEUi1D*>N z;LeKm3S7_*A||kX&MT1N-0Xc4 zW(N}6`~WNln%htR9mVPw5qR~~W-`fn8i4w2Qjf=}CKObE@&VXtd!*BpHFRp@KZZtR zg28>^^#z>QU&n&-{DmpGRnpniEeA(NeE?o}_RE7eYc<`yhE4;`fE4y7gXW_ziCMHn z2apJ~k_qhh7AI#_${gnh!&Ki2-aTTZIa*Y2P<|!N@Ij&m1KP!bMuRp`n8^ednu3K{ z;OSWcs8?^^pa5zxpd;!dGGgXHNXSYW ekolZ$(~Q5uZP!z+%fJ8t0000 { + 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..08cba0e7 --- /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.local.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.local.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/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..103eab82 100644 --- a/app/extension/src/background.ts +++ b/app/extension/src/background.ts @@ -1,7 +1,17 @@ import {log} from "./logger"; -import {readSyncStorageSettings} from "./storage"; -import {autoSaveArticle, saveArticle, sendData} from "./services"; +import {readSyncStorageSettings, getPromptsSettings} from "./storage"; +import {autoSaveArticle, saveArticle, sendData, fetchEnabledShortcuts, fetchGlobalSetting, 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 function startProcessingWithShortcuts(task: any, shortcuts: any[]) { if (!task) return; @@ -108,45 +118,288 @@ 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; + + // 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 || 'Chinese'; + + // Build the prompt: replace {lang} placeholder with target language + const systemPrompt = (shortcutContent || '').replace(/\{lang\}/g, defaultTargetLanguage); + + // 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 + const result = await streamText({ + model, + system: systemPrompt, + prompt: userPrompt, + maxTokens: 8000, + }); + + // Process the stream + for await (const textPart of result.textStream) { + 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; + } + } + + // Send completion message + 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) { + 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); } else if (msg.type === "save_clipper") { saveArticle(msg.payload); } else if (msg.type === 'auto_save_tweets') { - readSyncStorageSettings().then((settings) => { + readSyncStorageSettings().then(async (settings) => { if (settings.autoSaveTweet) { - // Add minLikes to payload - msg.payload.minLikes = settings.autoSaveTweetMinLikes; + // Fetch minLikes from server's GlobalSetting + const globalSetting = await fetchGlobalSetting(); + msg.payload.minLikes = globalSetting?.autoSaveTweetMinLikes ?? 0; 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)) { 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 } }); +// 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..998360b0 --- /dev/null +++ b/app/extension/src/components/AIProviderDialog.tsx @@ -0,0 +1,509 @@ +import React, { useEffect, useState } from 'react'; +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + 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 [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)); + + if (config) { + setApiKey(config.apiKey); + // Only set baseUrl if it was explicitly saved (not the default) + setBaseUrl(config.baseUrl || ''); + // 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(''); + // 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') { + refreshOllamaModels(); + } + }; + + const refreshOllamaModels = async () => { + setLoadingModels(true); + try { + const models = await fetchOllamaModels(baseUrl || 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: true, + updatedAt: Date.now(), + }; + await saveProviderConfig(providerType, config); + onClose(); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + 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; + const canSave = + (providerType === 'ollama' || apiKey.trim() !== '') && allSelectedCount > 0; + + 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 + + )} + + + + + + + + + + + + + ); +}; diff --git a/app/extension/src/components/AIProvidersSettings.tsx b/app/extension/src/components/AIProvidersSettings.tsx new file mode 100644 index 00000000..52b477cf --- /dev/null +++ b/app/extension/src/components/AIProvidersSettings.tsx @@ -0,0 +1,235 @@ +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

+

+ Configure AI providers to enable AI-powered features in Huntly + extension. API keys are stored locally and never sent to Huntly + servers. +

+
+ + + {visibleProviders.map((type) => { + const meta = PROVIDER_REGISTRY[type]; + const config = providers[type]; + const isConfigured = config?.enabled; + + return ( + + {isConfigured && ( + + )} + + + + {PROVIDER_ICONS[type]} + + + + {meta.displayName} + + + {meta.description} + + + + + + {isConfigured && config ? ( + 1 ? 's' : ''}`} + size="small" + color="primary" + 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..7ddfecc5 --- /dev/null +++ b/app/extension/src/components/AIToolbar.tsx @@ -0,0 +1,501 @@ +import React, { useState, useEffect } from 'react'; +import { + Button, + Menu, + MenuItem, + ListSubheader, + Divider, + CircularProgress, + Typography, + Box, +} 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 { + Prompt, + getPromptsSettings, +} 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; + 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, + 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 || externalModels?.defaultModel || null + ); + const [loadingModels, setLoadingModels] = useState(!useExternalModels); + + // 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) { + const defaultProviderType = await getEffectiveDefaultProviderType(); + if (defaultProviderType) { + const defaultModel = modelList.find(m => m.provider === defaultProviderType); + setSelectedModel(defaultModel || modelList[0]); + } else { + setSelectedModel(modelList[0]); + } + } + } 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); + handleModelMenuClose(); + }; + + const openSettings = (tab?: string) => { + const optionsUrl = chrome.runtime.getURL('options.html'); + const url = tab ? `${optionsUrl}#${tab}` : optionsUrl; + chrome.tabs.create({ 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 */} + + + + {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..82248963 --- /dev/null +++ b/app/extension/src/components/ArticlePreview.tsx @@ -0,0 +1,301 @@ +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 = () => ( + + + +); + +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; + + // 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]); + + // 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 &&

{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..eedd30f0 --- /dev/null +++ b/app/extension/src/components/PromptsSettings.tsx @@ -0,0 +1,478 @@ +import React, { useEffect, useState } from 'react'; +import { + Box, + Button, + TextField, + Typography, + IconButton, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + List, + ListItem, + ListItemText, + ListItemSecondaryAction, + Paper, + Divider, + FormControl, + InputLabel, + Select, + MenuItem, + Snackbar, + Chip, + Switch, +} 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, +} from '../storage'; + +const COMMON_LANGUAGES = [ + 'Chinese', + 'English', + 'Japanese', + 'Korean', + 'Spanish', + 'French', + 'German', + 'Russian', + 'Portuguese', + 'Italian', + 'Arabic', +]; + +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 target language" + /> + + + + + + + ); +}; + +export const PromptsSettings: React.FC = () => { + const [defaultTargetLanguage, setDefaultTargetLanguage] = useState('Chinese'); + 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 loadSettings = async () => { + const settings = await getPromptsSettings(); + setDefaultTargetLanguage(settings.defaultTargetLanguage); + setPrompts(settings.prompts); + }; + + 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) => { + setDefaultTargetLanguage(language); + await saveSettings(language, 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. Use{' '} + + {'{lang}'} + {' '} + in prompt content as a placeholder for the target language. +

+
+ + {/* Default Target Language */} + + + Default Target Language + + + + + {/* 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} + + } + /> + + 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} + + } + /> + + handleViewPrompt(prompt)} + title="View" + > + + + + + + ))} + + + + + { + setDialogOpen(false); + setEditingPrompt(null); + }} + onSave={handleSavePrompt} + /> + + { + setViewDialogOpen(false); + setViewingPrompt(null); + }} + /> + + setSnackbarOpen(false)} + message={snackbarMessage} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + /> +
+ ); +}; 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..15d7f7df --- /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/model.d.ts b/app/extension/src/model.d.ts index ea9a9d31..740efefc 100644 --- a/app/extension/src/model.d.ts +++ b/app/extension/src/model.d.ts @@ -26,7 +26,7 @@ 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', + 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' | 'get_huntly_shortcuts' | 'get_ai_toolbar_data', payload?: any } 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..8dce0328 --- /dev/null +++ b/app/extension/src/parser/contentParser.ts @@ -0,0 +1,68 @@ +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); +} + +/** + * Parse document using Mozilla Readability + */ +function parseWithReadability(doc: Document): ParsedArticle | null { + const article = new Readability(doc, { debug: false }).parse(); + if (!article) { + return null; + } + return { + title: article.title || "", + content: article.content || "", + excerpt: article.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; + } + return { + title: result.title || "", + content: result.content || "", + excerpt: result.description || "", + 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..f426f1f2 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 VisibilityIcon from '@mui/icons-material/Visibility'; import {log} from "./logger"; import { archivePage, @@ -42,22 +40,25 @@ 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"; 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); + // Tabs const [activeTab, setActiveTab] = useState(0); const [snippetPage, setSnippetPage] = useState(null); @@ -70,12 +71,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); @@ -122,7 +119,9 @@ const Popup = () => { function setSettingsState(settings: StorageSettings) { setStorageSettings(settings); 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 +129,6 @@ const Popup = () => { setUsername(result.username); loadPageInfo(); - // 加载快捷指令 - fetchShortcuts(); }).catch(() => { setUsername(null); }).finally(() => { @@ -140,16 +137,64 @@ const Popup = () => { } } - function loadPageInfo() { + function loadPageInfoOnly(customParserType?: ContentParserType) { + 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) { + setPage(response.page); + if (response.parserType) { + setParserType(response.parserType); + } + if (!response.page) { + setParseFailed(true); + } + } else { + setParseFailed(true); + } + }); + } else { + setParsingArticle(false); + setParseFailed(true); + } + }); + } + + function loadPageInfo(customParserType?: ContentParserType) { + 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); + if (response.parserType) { + setParserType(response.parserType); + } + if (response.page) { + loadPageOperateResult(autoSavedPageId, response.page.url, setArticleOperateResult); + } else { + setParseFailed(true); + } + } else { + setParseFailed(true); } }); + } else { + setParsingArticle(false); + setParseFailed(true); } }); } @@ -198,8 +243,8 @@ const Popup = () => { }); } - function toggleShowOptions() { - setShowOptions(!showOptions); + function openOptionsPage() { + chrome.runtime.openOptionsPage(); } function getDomain(serverUrl: string) { @@ -207,11 +252,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 +391,80 @@ 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(); + chrome.tabs.sendMessage(tab.id, { + type: 'shortcuts_preview', + payload: { + page: activePage, + 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, + 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 +481,7 @@ const Popup = () => { } - +
@@ -476,24 +489,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 +506,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 +539,42 @@ const Popup = () => { {/* Article Tab Content */}