From b241f32a31f66ac3a8df7b772b78e5c8e1ae1d9e Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Sun, 18 Jan 2026 02:17:53 +0800 Subject: [PATCH] feat(airi-plugin-web-extension): integrated to server-sdk, now supports to read context from browser --- plugins/airi-plugin-web-extension/README.md | 14 + .../entrypoints/background.ts | 159 +++++++- .../entrypoints/content.ts | 13 +- .../entrypoints/popup/App.vue | 58 ++- .../popup/components/header/index.ts | 1 + .../popup/components/header/popup.vue | 35 ++ .../entrypoints/popup/components/index.ts | 2 + .../popup/components/sections/index.ts | 3 + .../sections/settings/connection.vue | 33 ++ .../sections/settings/preference-capture.vue | 35 ++ .../sections/visualize-live-vision.vue | 30 ++ .../entrypoints/popup/index.html | 2 +- .../entrypoints/popup/stores/index.ts | 1 + .../entrypoints/popup/stores/popup.ts | 128 +++++++ .../entrypoints/popup/style.css | 61 +--- .../airi-plugin-web-extension/package.json | 8 +- .../src/background/client.ts | 239 +++++++++++++ .../src/background/storage.ts | 23 ++ .../src/content/index.ts | 338 ++++++++++++++++++ .../src/popup/bridge.ts | 32 ++ .../src/shared/constants.ts | 16 + .../src/shared/sites.ts | 43 +++ .../src/shared/types.ts | 88 +++++ .../airi-plugin-web-extension/wxt.config.ts | 14 + pnpm-lock.yaml | 36 +- pnpm-workspace.yaml | 2 +- 26 files changed, 1337 insertions(+), 77 deletions(-) create mode 100644 plugins/airi-plugin-web-extension/entrypoints/popup/components/header/index.ts create mode 100644 plugins/airi-plugin-web-extension/entrypoints/popup/components/header/popup.vue create mode 100644 plugins/airi-plugin-web-extension/entrypoints/popup/components/index.ts create mode 100644 plugins/airi-plugin-web-extension/entrypoints/popup/components/sections/index.ts create mode 100644 plugins/airi-plugin-web-extension/entrypoints/popup/components/sections/settings/connection.vue create mode 100644 plugins/airi-plugin-web-extension/entrypoints/popup/components/sections/settings/preference-capture.vue create mode 100644 plugins/airi-plugin-web-extension/entrypoints/popup/components/sections/visualize-live-vision.vue create mode 100644 plugins/airi-plugin-web-extension/entrypoints/popup/stores/index.ts create mode 100644 plugins/airi-plugin-web-extension/entrypoints/popup/stores/popup.ts create mode 100644 plugins/airi-plugin-web-extension/src/background/client.ts create mode 100644 plugins/airi-plugin-web-extension/src/background/storage.ts create mode 100644 plugins/airi-plugin-web-extension/src/content/index.ts create mode 100644 plugins/airi-plugin-web-extension/src/popup/bridge.ts create mode 100644 plugins/airi-plugin-web-extension/src/shared/constants.ts create mode 100644 plugins/airi-plugin-web-extension/src/shared/sites.ts create mode 100644 plugins/airi-plugin-web-extension/src/shared/types.ts diff --git a/plugins/airi-plugin-web-extension/README.md b/plugins/airi-plugin-web-extension/README.md index 62e294620..88c66470f 100644 --- a/plugins/airi-plugin-web-extension/README.md +++ b/plugins/airi-plugin-web-extension/README.md @@ -3,3 +3,17 @@ > Read what you are reading! This is a plugin for the AIRI to understand what you are reading, looking at, or listening to on the web. + +## What it does now + +- Captures page + video context from YouTube and Bilibili. +- Extracts subtitles from text tracks or DOM overlays. +- Sends context updates and optional `spark:notify` events to the character. +- Exposes a popup to configure WebSocket, toggles, and quick status. + +## Quick start + +1. `pnpm -F @proj-airi/airi-plugin-web-extension dev` +2. Load the unpacked extension from `.wxt/dev` in your browser. +3. Open the popup to set the WebSocket URL (default: `ws://localhost:6121/ws`). +4. Watch a YouTube/Bilibili video and confirm the popup shows the detected title/subtitle. diff --git a/plugins/airi-plugin-web-extension/entrypoints/background.ts b/plugins/airi-plugin-web-extension/entrypoints/background.ts index eb97c27d3..27e5e4f9d 100644 --- a/plugins/airi-plugin-web-extension/entrypoints/background.ts +++ b/plugins/airi-plugin-web-extension/entrypoints/background.ts @@ -1,4 +1,159 @@ +import type { + BackgroundToContentMessage, + ContentToBackgroundMessage, + ExtensionSettings, + PopupToBackgroundMessage, +} from '../src/shared/types' + +import { + createClientState, + ensureClient, + handlePageContext, + handleSubtitle, + handleVideoContext, + toStatus, +} from '../src/background/client' +import { loadSettings, saveSettings } from '../src/background/storage' +import { DEFAULT_SETTINGS, STORAGE_KEY } from '../src/shared/constants' +import { detectSiteFromUrl } from '../src/shared/sites' + +const state = createClientState() + +let settings: ExtensionSettings = { ...DEFAULT_SETTINGS } +let lastVideoNotifyKey = '' +let lastStatusSentAt = 0 +let connectionKey = '' + +async function refreshClient() { + const nextKey = `${settings.enabled}:${settings.wsUrl}:${settings.token}` + if (nextKey !== connectionKey) { + connectionKey = nextKey + if (state.client) + state.client.close() + state.client = null + state.connected = false + } + await ensureClient(state, settings) +} + +function buildNotifyKey(payload: { url: string, title?: string, videoId?: string }) { + return [payload.videoId, payload.title, payload.url].filter(Boolean).join('|') +} + +function shouldNotifyVideo(payload: { url: string, title?: string, videoId?: string }) { + const key = buildNotifyKey(payload) + if (!key || key === lastVideoNotifyKey) + return false + lastVideoNotifyKey = key + return true +} + +function emitStatus() { + const now = Date.now() + if (now - lastStatusSentAt < 300) + return + + lastStatusSentAt = now + void browser.runtime.sendMessage({ type: 'background:status', payload: toStatus(state, settings) }).catch(() => {}) +} + +async function updateSettings(partial: Partial) { + settings = await saveSettings(partial) + await refreshClient() + emitStatus() +} + +async function init() { + settings = await loadSettings() + await refreshClient() + emitStatus() +} + +function handleContentMessage(message: ContentToBackgroundMessage) { + switch (message.type) { + case 'content:page': { + const payload = { + ...message.payload, + site: message.payload.site === 'unknown' ? detectSiteFromUrl(message.payload.url) : message.payload.site, + } + handlePageContext(state, settings, payload) + emitStatus() + break + } + case 'content:video': { + const payload = { + ...message.payload, + site: message.payload.site === 'unknown' ? detectSiteFromUrl(message.payload.url) : message.payload.site, + } + handleVideoContext(state, settings, payload, { notify: shouldNotifyVideo(payload) }) + emitStatus() + break + } + case 'content:subtitle': { + const payload = { + ...message.payload, + site: message.payload.site === 'unknown' ? detectSiteFromUrl(message.payload.url) : message.payload.site, + } + handleSubtitle(state, settings, payload) + emitStatus() + break + } + case 'content:vision:frame': { + state.lastVisionFrameAt = Date.now() + emitStatus() + break + } + } +} + +async function handlePopupMessage(message: PopupToBackgroundMessage) { + switch (message.type) { + case 'popup:get-status': + return toStatus(state, settings) + case 'popup:update-settings': + await updateSettings(message.payload) + return toStatus(state, settings) + case 'popup:toggle-enabled': + await updateSettings({ enabled: message.payload }) + return toStatus(state, settings) + case 'popup:request-vision-frame': { + const message: BackgroundToContentMessage = { type: 'background:request-vision-frame' } + const tabs = await browser.tabs.query({ active: true, currentWindow: true }) + const tab = tabs[0] + if (tab?.id != null) { + await browser.tabs.sendMessage(tab.id, message).catch(() => {}) + } + return toStatus(state, settings) + } + case 'popup:clear-error': + state.lastError = undefined + emitStatus() + return toStatus(state, settings) + } +} + export default defineBackground(() => { - // eslint-disable-next-line no-console - console.log('Hello background!', { id: browser.runtime.id }) + void init() + + browser.runtime.onMessage.addListener((message: ContentToBackgroundMessage | PopupToBackgroundMessage) => { + if (message && typeof message === 'object' && 'type' in message) { + if (message.type.startsWith('content:')) { + handleContentMessage(message as ContentToBackgroundMessage) + return + } + + if (message.type.startsWith('popup:')) { + return handlePopupMessage(message as PopupToBackgroundMessage) + } + } + }) + + browser.storage.onChanged.addListener((changes) => { + if (changes[STORAGE_KEY]) { + const next = changes[STORAGE_KEY].newValue as ExtensionSettings | undefined + settings = { ...DEFAULT_SETTINGS, ...next } + void refreshClient() + emitStatus() + } + }) }) diff --git a/plugins/airi-plugin-web-extension/entrypoints/content.ts b/plugins/airi-plugin-web-extension/entrypoints/content.ts index 9ec5822f5..9f7eb1a7e 100644 --- a/plugins/airi-plugin-web-extension/entrypoints/content.ts +++ b/plugins/airi-plugin-web-extension/entrypoints/content.ts @@ -1,7 +1,14 @@ +import { startContentObserver } from '../src/content' + export default defineContentScript({ - matches: ['*://*.google.com/*'], + matches: [ + '*://*.youtube.com/*', + '*://*.youtu.be/*', + '*://*.bilibili.com/*', + '*://*.b23.tv/*', + ], + runAt: 'document_idle', main() { - // eslint-disable-next-line no-console - console.log('Hello content.') + startContentObserver() }, }) diff --git a/plugins/airi-plugin-web-extension/entrypoints/popup/App.vue b/plugins/airi-plugin-web-extension/entrypoints/popup/App.vue index c6c83b0b1..ff22b8bae 100644 --- a/plugins/airi-plugin-web-extension/entrypoints/popup/App.vue +++ b/plugins/airi-plugin-web-extension/entrypoints/popup/App.vue @@ -1,15 +1,53 @@ diff --git a/plugins/airi-plugin-web-extension/entrypoints/popup/components/header/index.ts b/plugins/airi-plugin-web-extension/entrypoints/popup/components/header/index.ts new file mode 100644 index 000000000..86a80405c --- /dev/null +++ b/plugins/airi-plugin-web-extension/entrypoints/popup/components/header/index.ts @@ -0,0 +1 @@ +export { default as HeaderPopup } from './popup.vue' diff --git a/plugins/airi-plugin-web-extension/entrypoints/popup/components/header/popup.vue b/plugins/airi-plugin-web-extension/entrypoints/popup/components/header/popup.vue new file mode 100644 index 000000000..a633e210d --- /dev/null +++ b/plugins/airi-plugin-web-extension/entrypoints/popup/components/header/popup.vue @@ -0,0 +1,35 @@ + + + diff --git a/plugins/airi-plugin-web-extension/entrypoints/popup/components/index.ts b/plugins/airi-plugin-web-extension/entrypoints/popup/components/index.ts new file mode 100644 index 000000000..b3e0923bf --- /dev/null +++ b/plugins/airi-plugin-web-extension/entrypoints/popup/components/index.ts @@ -0,0 +1,2 @@ +export * from './header' +export * from './sections' diff --git a/plugins/airi-plugin-web-extension/entrypoints/popup/components/sections/index.ts b/plugins/airi-plugin-web-extension/entrypoints/popup/components/sections/index.ts new file mode 100644 index 000000000..a5abf7b53 --- /dev/null +++ b/plugins/airi-plugin-web-extension/entrypoints/popup/components/sections/index.ts @@ -0,0 +1,3 @@ +export { default as SettingsConnection } from './settings/connection.vue' +export { default as PreferenceCapture } from './settings/preference-capture.vue' +export { default as VisualizeLiveVision } from './visualize-live-vision.vue' diff --git a/plugins/airi-plugin-web-extension/entrypoints/popup/components/sections/settings/connection.vue b/plugins/airi-plugin-web-extension/entrypoints/popup/components/sections/settings/connection.vue new file mode 100644 index 000000000..eb03c1aa9 --- /dev/null +++ b/plugins/airi-plugin-web-extension/entrypoints/popup/components/sections/settings/connection.vue @@ -0,0 +1,33 @@ + + + diff --git a/plugins/airi-plugin-web-extension/entrypoints/popup/components/sections/settings/preference-capture.vue b/plugins/airi-plugin-web-extension/entrypoints/popup/components/sections/settings/preference-capture.vue new file mode 100644 index 000000000..fde4a541c --- /dev/null +++ b/plugins/airi-plugin-web-extension/entrypoints/popup/components/sections/settings/preference-capture.vue @@ -0,0 +1,35 @@ + + + diff --git a/plugins/airi-plugin-web-extension/entrypoints/popup/components/sections/visualize-live-vision.vue b/plugins/airi-plugin-web-extension/entrypoints/popup/components/sections/visualize-live-vision.vue new file mode 100644 index 000000000..fc4f672a3 --- /dev/null +++ b/plugins/airi-plugin-web-extension/entrypoints/popup/components/sections/visualize-live-vision.vue @@ -0,0 +1,30 @@ + + + diff --git a/plugins/airi-plugin-web-extension/entrypoints/popup/index.html b/plugins/airi-plugin-web-extension/entrypoints/popup/index.html index 5a2184e15..889039433 100644 --- a/plugins/airi-plugin-web-extension/entrypoints/popup/index.html +++ b/plugins/airi-plugin-web-extension/entrypoints/popup/index.html @@ -3,7 +3,7 @@ - Default Popup Title + AIRI Web Extension diff --git a/plugins/airi-plugin-web-extension/entrypoints/popup/stores/index.ts b/plugins/airi-plugin-web-extension/entrypoints/popup/stores/index.ts new file mode 100644 index 000000000..e9721a6a0 --- /dev/null +++ b/plugins/airi-plugin-web-extension/entrypoints/popup/stores/index.ts @@ -0,0 +1 @@ +export * from './popup' diff --git a/plugins/airi-plugin-web-extension/entrypoints/popup/stores/popup.ts b/plugins/airi-plugin-web-extension/entrypoints/popup/stores/popup.ts new file mode 100644 index 000000000..e941be60c --- /dev/null +++ b/plugins/airi-plugin-web-extension/entrypoints/popup/stores/popup.ts @@ -0,0 +1,128 @@ +import type { ExtensionSettings, ExtensionStatus } from '../../../src/shared/types' + +import { createGlobalState } from '@vueuse/core' +import { computed, reactive, ref, watch } from 'vue' + +import { clearError, onBackgroundStatus, requestStatus, requestVisionFrame, toggleEnabled, updateSettings } from '../../../src/popup/bridge' + +const STORAGE_KEY = 'airi-popup-settings' + +export const usePopupStore = createGlobalState(() => { + const status = ref(null) + const syncing = ref(true) + const initialized = ref(false) + + const form = reactive({ + wsUrl: '', + token: '', + enabled: true, + sendPageContext: true, + sendVideoContext: true, + sendSubtitles: true, + sendSparkNotify: true, + enableVision: false, + }) + + const connected = computed(() => status.value?.connected ?? false) + const lastVideo = computed(() => status.value?.lastVideo) + const lastSubtitle = computed(() => status.value?.lastSubtitle) + const lastError = computed(() => status.value?.lastError) + + function hydrate(next: ExtensionStatus) { + status.value = next + Object.assign(form, next.settings) + } + + function loadStoredSettings() { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) + return + const parsed = JSON.parse(raw) as Partial + Object.assign(form, parsed) + } + catch { + localStorage.removeItem(STORAGE_KEY) + } + } + + function persistSettings() { + const payload: ExtensionSettings = { ...form } + localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)) + } + + async function refresh() { + syncing.value = true + try { + const next = await requestStatus() + hydrate(next) + } + finally { + syncing.value = false + } + } + + async function applySettings() { + syncing.value = true + try { + const next = await updateSettings({ ...form }) + hydrate(next) + } + finally { + syncing.value = false + } + } + + async function toggle() { + syncing.value = true + try { + const next = await toggleEnabled(!form.enabled) + hydrate(next) + } + finally { + syncing.value = false + } + } + + async function captureFrame() { + syncing.value = true + try { + const next = await requestVisionFrame() + hydrate(next) + } + finally { + syncing.value = false + } + } + + async function clearLastError() { + const next = await clearError() + hydrate(next) + } + + function init() { + if (initialized.value) + return + initialized.value = true + loadStoredSettings() + watch(form, persistSettings, { deep: true }) + void refresh() + onBackgroundStatus(hydrate) + } + + return { + status, + syncing, + form, + connected, + lastVideo, + lastSubtitle, + lastError, + init, + refresh, + applySettings, + toggle, + captureFrame, + clearLastError, + } +}) diff --git a/plugins/airi-plugin-web-extension/entrypoints/popup/style.css b/plugins/airi-plugin-web-extension/entrypoints/popup/style.css index 7294765e0..4fe918751 100644 --- a/plugins/airi-plugin-web-extension/entrypoints/popup/style.css +++ b/plugins/airi-plugin-web-extension/entrypoints/popup/style.css @@ -1,11 +1,10 @@ :root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; + --bg-color-light: rgb(255 255 255); + --bg-color-dark: rgb(18 18 18); + --bg-color: var(--bg-color-light); color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; + background-color: var(--bg-color-dark); font-synthesis: none; text-rendering: optimizeLegibility; @@ -14,67 +13,23 @@ -webkit-text-size-adjust: 100%; } -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - body { margin: 0; display: flex; place-items: center; - min-width: 320px; + min-width: 600px; min-height: 100vh; } -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -.card { - padding: 2em; -} - #app { + width: 100%; max-width: 1280px; margin: 0 auto; - padding: 2rem; - text-align: center; + padding: 1rem; } @media (prefers-color-scheme: light) { :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; + background-color: var(--bg-color-light); } } diff --git a/plugins/airi-plugin-web-extension/package.json b/plugins/airi-plugin-web-extension/package.json index 5f47689d6..f31f75f61 100644 --- a/plugins/airi-plugin-web-extension/package.json +++ b/plugins/airi-plugin-web-extension/package.json @@ -15,11 +15,17 @@ "postinstall": "wxt prepare" }, "dependencies": { - "vue": "catalog:" + "@vueuse/core": "catalog:" }, "devDependencies": { + "@iconify-json/solar": "^1.2.5", + "@iconify-json/svg-spinners": "^1.2.4", + "@proj-airi/server-sdk": "workspace:^", + "@proj-airi/ui": "workspace:^", "@unocss/reset": "^66.5.11", "@wxt-dev/module-vue": "^1.0.3", + "nanoid": "^5.1.6", + "vue": "catalog:", "vue-tsc": "^3.1.8", "wxt": "^0.20.13" } diff --git a/plugins/airi-plugin-web-extension/src/background/client.ts b/plugins/airi-plugin-web-extension/src/background/client.ts new file mode 100644 index 000000000..3cd137ccf --- /dev/null +++ b/plugins/airi-plugin-web-extension/src/background/client.ts @@ -0,0 +1,239 @@ +import type { ContextUpdate } from '@proj-airi/server-sdk' + +import type { ExtensionSettings, ExtensionStatus, PageContextPayload, SubtitlePayload, VideoContextPayload } from '../shared/types' + +import { Client, ContextUpdateStrategy } from '@proj-airi/server-sdk' +import { nanoid } from 'nanoid' + +import packageJSON from '../../package.json' + +const PLUGIN_NAME = 'proj-airi:plugin-web-extension' + +export interface ClientState { + client: Client | null + connected: boolean + lastError?: string + lastPage?: PageContextPayload + lastVideo?: VideoContextPayload + lastSubtitle?: SubtitlePayload + lastVisionFrameAt?: number +} + +export function createClientState(): ClientState { + return { + client: null, + connected: false, + } +} + +function createIdentity() { + return { + plugin: PLUGIN_NAME, + instanceId: nanoid(), + version: typeof packageJSON.version === 'string' ? packageJSON.version : undefined, + labels: { + runtime: 'web-extension', + }, + } +} + +export function toStatus(state: ClientState, settings: ExtensionSettings): ExtensionStatus { + return { + connected: state.connected, + lastError: state.lastError, + settings, + lastPage: state.lastPage, + lastVideo: state.lastVideo, + lastSubtitle: state.lastSubtitle, + lastVisionFrameAt: state.lastVisionFrameAt, + } +} + +export async function ensureClient(state: ClientState, settings: ExtensionSettings) { + if (!settings.enabled) { + disconnectClient(state) + return + } + + if (state.client) { + return + } + + const client = new Client({ + name: PLUGIN_NAME, + url: settings.wsUrl, + token: settings.token || undefined, + identity: createIdentity(), + possibleEvents: ['context:update', 'spark:notify', 'spark:emit'], + autoConnect: false, + autoReconnect: true, + onError: (error) => { + state.connected = false + state.lastError = error instanceof Error ? error.message : String(error) + }, + onClose: () => { + state.connected = false + }, + }) + + state.client = client + + try { + await client.connect() + state.connected = true + state.lastError = undefined + } + catch (error) { + state.connected = false + state.lastError = error instanceof Error ? error.message : String(error) + } +} + +export function disconnectClient(state: ClientState) { + if (!state.client) + return + + state.client.close() + state.client = null + state.connected = false +} + +function sendContextUpdate(state: ClientState, update: Omit & Partial>) { + if (!state.client || !state.connected) + return + + const id = update.id ?? nanoid() + state.client.send({ + type: 'context:update', + data: { + id, + contextId: update.contextId ?? id, + ...update, + }, + }) +} + +function sendSparkNotify(state: ClientState, data: { headline: string, note?: string, payload?: Record }) { + if (!state.client || !state.connected) + return + + state.client.send({ + type: 'spark:notify', + data: { + id: nanoid(), + eventId: nanoid(), + kind: 'ping', + urgency: 'soon', + headline: data.headline, + note: data.note, + payload: data.payload, + destinations: ['character'], + }, + }) +} + +export function handlePageContext(state: ClientState, settings: ExtensionSettings, payload: PageContextPayload) { + state.lastPage = payload + + if (!settings.enabled || !settings.sendPageContext) + return + + sendContextUpdate(state, { + strategy: ContextUpdateStrategy.ReplaceSelf, + lane: 'web:page', + text: `User is browsing: ${payload.title} (${payload.url}).`, + metadata: { + source: 'web-extension', + site: payload.site, + url: payload.url, + title: payload.title, + description: payload.description, + language: payload.language, + }, + }) +} + +export function handleVideoContext( + state: ClientState, + settings: ExtensionSettings, + payload: VideoContextPayload, + options?: { notify?: boolean }, +) { + state.lastVideo = payload + + if (!settings.enabled || !settings.sendVideoContext) + return + + const headline = payload.title + ? `User is watching: ${payload.title}` + : 'User is watching a video' + + if (settings.sendSparkNotify && options?.notify !== false && payload.title) { + sendSparkNotify(state, { + headline, + note: payload.channel ? `Channel: ${payload.channel}` : undefined, + payload: { + site: payload.site, + url: payload.url, + title: payload.title, + channel: payload.channel, + videoId: payload.videoId, + durationSec: payload.durationSec, + currentTimeSec: payload.currentTimeSec, + isPlaying: payload.isPlaying, + isLive: payload.isLive, + }, + }) + } + + sendContextUpdate(state, { + strategy: ContextUpdateStrategy.ReplaceSelf, + lane: 'web:video', + text: [ + headline, + payload.channel ? `Channel: ${payload.channel}.` : undefined, + payload.currentTimeSec != null + ? `Progress: ${Math.floor(payload.currentTimeSec)}s${payload.durationSec ? ` / ${Math.floor(payload.durationSec)}s` : ''}.` + : undefined, + payload.url ? `URL: ${payload.url}.` : undefined, + ].filter(Boolean).join(' '), + metadata: { + source: 'web-extension', + site: payload.site, + url: payload.url, + title: payload.title, + channel: payload.channel, + videoId: payload.videoId, + durationSec: payload.durationSec, + currentTimeSec: payload.currentTimeSec, + isPlaying: payload.isPlaying, + playbackRate: payload.playbackRate, + isLive: payload.isLive, + playerSize: payload.playerSize, + }, + }) +} + +export function handleSubtitle(state: ClientState, settings: ExtensionSettings, payload: SubtitlePayload) { + state.lastSubtitle = payload + + if (!settings.enabled || !settings.sendSubtitles) + return + + sendContextUpdate(state, { + strategy: ContextUpdateStrategy.ReplaceSelf, + lane: 'web:subtitle', + text: `Subtitle: ${payload.text}`, + metadata: { + source: 'web-extension', + site: payload.site, + url: payload.url, + title: payload.title, + videoId: payload.videoId, + language: payload.language, + startMs: payload.startMs, + endMs: payload.endMs, + isAuto: payload.isAuto, + }, + }) +} diff --git a/plugins/airi-plugin-web-extension/src/background/storage.ts b/plugins/airi-plugin-web-extension/src/background/storage.ts new file mode 100644 index 000000000..2b8ebc66f --- /dev/null +++ b/plugins/airi-plugin-web-extension/src/background/storage.ts @@ -0,0 +1,23 @@ +import type { ExtensionSettings } from '../shared/types' + +import { DEFAULT_SETTINGS, STORAGE_KEY } from '../shared/constants' + +export async function loadSettings(): Promise { + const stored = await browser.storage.local.get(STORAGE_KEY) + const value = stored[STORAGE_KEY] as ExtensionSettings | undefined + return { + ...DEFAULT_SETTINGS, + ...value, + } +} + +export async function saveSettings(partial: Partial): Promise { + const next = { + ...DEFAULT_SETTINGS, + ...(await loadSettings()), + ...partial, + } + + await browser.storage.local.set({ [STORAGE_KEY]: next }) + return next +} diff --git a/plugins/airi-plugin-web-extension/src/content/index.ts b/plugins/airi-plugin-web-extension/src/content/index.ts new file mode 100644 index 000000000..80eedae59 --- /dev/null +++ b/plugins/airi-plugin-web-extension/src/content/index.ts @@ -0,0 +1,338 @@ +import type { BackgroundToContentMessage, ContentToBackgroundMessage, PageContextPayload, SubtitlePayload, VideoContextPayload, VideoSite, VisionFramePayload } from '../shared/types' + +import { detectSiteFromUrl, extractVideoId, normalizeText } from '../shared/sites' + +const VIDEO_PROGRESS_INTERVAL = 15000 +const TITLE_POLL_INTERVAL = 2000 +const SUBTITLE_DEDUPE_WINDOW = 2000 + +const lastPayloadByType = new Map() + +function safeSend(message: ContentToBackgroundMessage) { + const serialized = JSON.stringify(message.payload) + const lastSerialized = lastPayloadByType.get(message.type) + if (serialized === lastSerialized) + return + + lastPayloadByType.set(message.type, serialized) + void browser.runtime.sendMessage(message).catch(() => {}) +} + +function buildPageContext(site: VideoSite): PageContextPayload { + const description = normalizeText(document.querySelector('meta[name="description"]')?.getAttribute('content')) + const ogDescription = normalizeText(document.querySelector('meta[property="og:description"]')?.getAttribute('content')) + + return { + site, + url: location.href, + title: normalizeText(document.title), + description: description || ogDescription || undefined, + language: document.documentElement.lang || undefined, + } +} + +function buildVideoContext(site: VideoSite, video: HTMLVideoElement, includeProgress = false): VideoContextPayload { + const title = normalizeText(findVideoTitle(site)) + const channel = normalizeText(findChannelName(site)) + const url = location.href + const videoId = extractVideoId(site, url) + const durationSec = Number.isFinite(video.duration) ? Math.floor(video.duration) : undefined + const currentTimeSec = includeProgress && Number.isFinite(video.currentTime) ? Math.floor(video.currentTime) : undefined + const rect = video.getBoundingClientRect() + + return { + site, + url, + title: title || normalizeText(document.title), + channel: channel || undefined, + videoId, + durationSec, + currentTimeSec, + isPlaying: !video.paused && !video.ended, + isMuted: video.muted, + volume: Number.isFinite(video.volume) ? Number(video.volume.toFixed(2)) : undefined, + playbackRate: Number.isFinite(video.playbackRate) ? Number(video.playbackRate.toFixed(2)) : undefined, + playerSize: rect.width && rect.height ? { width: Math.round(rect.width), height: Math.round(rect.height) } : undefined, + } +} + +function findVideoTitle(site: VideoSite) { + if (site === 'youtube') { + return ( + document.querySelector('ytd-watch-metadata h1 yt-formatted-string')?.textContent + || document.querySelector('h1.title yt-formatted-string')?.textContent + || document.querySelector('h1.title')?.textContent + ) + } + + if (site === 'bilibili') { + return ( + document.querySelector('h1.video-title')?.textContent + || document.querySelector('.video-title')?.textContent + || document.querySelector('h1')?.textContent + ) + } + + return document.querySelector('h1')?.textContent +} + +function findChannelName(site: VideoSite) { + if (site === 'youtube') { + return ( + document.querySelector('#channel-name a')?.textContent + || document.querySelector('ytd-channel-name a')?.textContent + || document.querySelector('ytd-channel-name')?.textContent + ) + } + + if (site === 'bilibili') { + return ( + document.querySelector('.up-name')?.textContent + || document.querySelector('.username')?.textContent + || document.querySelector('.up-info .name')?.textContent + ) + } + + return undefined +} + +function observeTextTracks(site: VideoSite, video: HTMLVideoElement, onSubtitle: (payload: SubtitlePayload) => void) { + const seen = new Map() + + const handleCueChange = (track: TextTrack) => { + const cues = Array.from(track.activeCues ?? []) as TextTrackCue[] + for (const cue of cues) { + const text = normalizeText((cue as VTTCue).text ?? '') + if (!text) + continue + + const key = `${text}:${Math.floor(cue.startTime * 1000)}` + const now = Date.now() + const lastSeen = seen.get(key) + if (lastSeen && now - lastSeen < SUBTITLE_DEDUPE_WINDOW) + continue + + seen.set(key, now) + onSubtitle({ + site, + url: location.href, + title: normalizeText(findVideoTitle(site)) || undefined, + videoId: extractVideoId(site, location.href), + text, + language: (track.language || track.label || undefined), + startMs: Math.floor(cue.startTime * 1000), + endMs: Math.floor(cue.endTime * 1000), + }) + } + } + + const attach = () => { + const tracks = Array.from(video.textTracks ?? []) + for (const track of tracks) { + if (track.kind && !['subtitles', 'captions'].includes(track.kind)) + continue + + if (track.mode === 'disabled') + track.mode = 'hidden' + track.oncuechange = () => handleCueChange(track) + } + } + + attach() + + const observer = new MutationObserver(() => attach()) + observer.observe(video, { attributes: true, childList: true, subtree: true }) + + return () => observer.disconnect() +} + +function observeSubtitleDom(site: VideoSite, onSubtitle: (payload: SubtitlePayload) => void) { + let selector = '' + if (site === 'youtube') + selector = '.caption-window .caption-window-text, .ytp-caption-segment' + if (site === 'bilibili') + selector = '.bpx-player-subtitle-panel-text, .bpx-player-subtitle-text' + + if (!selector) + return () => {} + + let lastText = '' + + const read = () => { + const nodes = Array.from(document.querySelectorAll(selector)) + const text = normalizeText(nodes.map(node => node.textContent).join(' ')) + if (!text || text === lastText) + return + + lastText = text + onSubtitle({ + site, + url: location.href, + title: normalizeText(findVideoTitle(site)) || undefined, + videoId: extractVideoId(site, location.href), + text, + }) + } + + const observer = new MutationObserver(read) + observer.observe(document.documentElement, { childList: true, subtree: true }) + + const interval = window.setInterval(read, 1200) + + return () => { + observer.disconnect() + window.clearInterval(interval) + } +} + +function captureVisionFrame(site: VideoSite, video: HTMLVideoElement): VisionFramePayload | null { + const canvas = document.createElement('canvas') + const width = Math.min(480, Math.max(1, Math.floor(video.videoWidth))) + const height = Math.min(270, Math.max(1, Math.floor(video.videoHeight))) + + if (!width || !height) + return null + + canvas.width = width + canvas.height = height + + const ctx = canvas.getContext('2d') + if (!ctx) + return null + + try { + ctx.drawImage(video, 0, 0, width, height) + return { + site, + url: location.href, + videoId: extractVideoId(site, location.href), + title: normalizeText(findVideoTitle(site)) || undefined, + capturedAt: Date.now(), + width, + height, + dataUrl: canvas.toDataURL('image/jpeg', 0.6), + } + } + catch { + return null + } +} + +function observeVideo(site: VideoSite) { + let video: HTMLVideoElement | null = null + let stopTracks: (() => void) | null = null + let stopDomSubtitles: (() => void) | null = null + let listenersAttached = false + + const sendVideo = (includeProgress: boolean) => { + if (!video) + return + + safeSend({ type: 'content:video', payload: buildVideoContext(site, video, includeProgress) }) + } + + const sendPage = () => { + safeSend({ type: 'content:page', payload: buildPageContext(site) }) + } + + const attach = () => { + const found = document.querySelector('video') as HTMLVideoElement | null + if (!found || found === video) + return + + if (video && listenersAttached) { + video.removeEventListener('play', onPlayback) + video.removeEventListener('pause', onPlayback) + video.removeEventListener('loadedmetadata', onPlayback) + listenersAttached = false + } + + video = found + stopTracks?.() + stopDomSubtitles?.() + + stopTracks = observeTextTracks(site, video, payload => safeSend({ type: 'content:subtitle', payload })) + stopDomSubtitles = observeSubtitleDom(site, payload => safeSend({ type: 'content:subtitle', payload })) + + sendPage() + sendVideo(false) + } + + const interval = window.setInterval(attach, 1000) + + const progressInterval = window.setInterval(() => { + if (!video) + return + sendVideo(true) + }, VIDEO_PROGRESS_INTERVAL) + + const titleInterval = window.setInterval(() => { + sendPage() + sendVideo(false) + }, TITLE_POLL_INTERVAL) + + const onPlayback = () => sendVideo(true) + + const cleanup = () => { + window.clearInterval(interval) + window.clearInterval(progressInterval) + window.clearInterval(titleInterval) + if (video) { + video.removeEventListener('play', onPlayback) + video.removeEventListener('pause', onPlayback) + video.removeEventListener('loadedmetadata', onPlayback) + listenersAttached = false + } + stopTracks?.() + stopDomSubtitles?.() + } + + const attachListeners = () => { + if (!video) + return + if (listenersAttached) + return + + video.addEventListener('play', onPlayback) + video.addEventListener('pause', onPlayback) + video.addEventListener('loadedmetadata', onPlayback) + listenersAttached = true + } + + const observer = new MutationObserver(() => { + attach() + attachListeners() + }) + + observer.observe(document.documentElement, { childList: true, subtree: true }) + + attach() + attachListeners() + + return () => { + cleanup() + observer.disconnect() + } +} + +export function startContentObserver() { + const site = detectSiteFromUrl(location.href) + safeSend({ type: 'content:page', payload: buildPageContext(site) }) + const stopVideo = observeVideo(site) + + browser.runtime.onMessage.addListener((message: BackgroundToContentMessage) => { + if (message.type === 'background:request-vision-frame') { + const video = document.querySelector('video') as HTMLVideoElement | null + if (!video) + return + + const frame = captureVisionFrame(site, video) + if (frame) + safeSend({ type: 'content:vision:frame', payload: frame }) + } + }) + + return () => { + stopVideo?.() + } +} diff --git a/plugins/airi-plugin-web-extension/src/popup/bridge.ts b/plugins/airi-plugin-web-extension/src/popup/bridge.ts new file mode 100644 index 000000000..9a281192c --- /dev/null +++ b/plugins/airi-plugin-web-extension/src/popup/bridge.ts @@ -0,0 +1,32 @@ +import type { BackgroundToPopupMessage, ExtensionSettings, ExtensionStatus, PopupToBackgroundMessage } from '../shared/types' + +export async function requestStatus(): Promise { + return await browser.runtime.sendMessage({ type: 'popup:get-status' } satisfies PopupToBackgroundMessage) +} + +export async function updateSettings(partial: Partial): Promise { + return await browser.runtime.sendMessage({ type: 'popup:update-settings', payload: partial } satisfies PopupToBackgroundMessage) +} + +export async function toggleEnabled(enabled: boolean): Promise { + return await browser.runtime.sendMessage({ type: 'popup:toggle-enabled', payload: enabled } satisfies PopupToBackgroundMessage) +} + +export async function requestVisionFrame(): Promise { + return await browser.runtime.sendMessage({ type: 'popup:request-vision-frame' } satisfies PopupToBackgroundMessage) +} + +export async function clearError(): Promise { + return await browser.runtime.sendMessage({ type: 'popup:clear-error' } satisfies PopupToBackgroundMessage) +} + +export function onBackgroundStatus(callback: (status: ExtensionStatus) => void) { + const listener = (message: BackgroundToPopupMessage) => { + if (message?.type === 'background:status') + callback(message.payload) + } + + browser.runtime.onMessage.addListener(listener) + + return () => browser.runtime.onMessage.removeListener(listener) +} diff --git a/plugins/airi-plugin-web-extension/src/shared/constants.ts b/plugins/airi-plugin-web-extension/src/shared/constants.ts new file mode 100644 index 000000000..67c3694c1 --- /dev/null +++ b/plugins/airi-plugin-web-extension/src/shared/constants.ts @@ -0,0 +1,16 @@ +import type { ExtensionSettings } from './types' + +export const DEFAULT_WS_URL = 'ws://localhost:6121/ws' + +export const DEFAULT_SETTINGS: ExtensionSettings = { + wsUrl: DEFAULT_WS_URL, + token: '', + enabled: true, + sendPageContext: true, + sendVideoContext: true, + sendSubtitles: true, + sendSparkNotify: true, + enableVision: false, +} + +export const STORAGE_KEY = 'airi:web-extension:settings' diff --git a/plugins/airi-plugin-web-extension/src/shared/sites.ts b/plugins/airi-plugin-web-extension/src/shared/sites.ts new file mode 100644 index 000000000..783af722e --- /dev/null +++ b/plugins/airi-plugin-web-extension/src/shared/sites.ts @@ -0,0 +1,43 @@ +import type { VideoSite } from './types' + +export function detectSiteFromUrl(url: string): VideoSite { + try { + const parsed = new URL(url) + const host = parsed.hostname + if (host.includes('youtube.com') || host.includes('youtu.be')) + return 'youtube' + if (host.includes('bilibili.com') || host.includes('b23.tv')) + return 'bilibili' + return 'unknown' + } + catch { + return 'unknown' + } +} + +export function extractVideoId(site: VideoSite, url: string): string | undefined { + try { + const parsed = new URL(url) + if (site === 'youtube') { + if (parsed.hostname.includes('youtu.be')) + return parsed.pathname.replace('/', '') || undefined + return parsed.searchParams.get('v') || undefined + } + if (site === 'bilibili') { + const parts = parsed.pathname.split('/').filter(Boolean) + const videoIndex = parts.findIndex(part => part === 'video') + if (videoIndex >= 0) + return parts[videoIndex + 1] + return parts[0] + } + } + catch { + return undefined + } + + return undefined +} + +export function normalizeText(value: string | null | undefined) { + return value?.replace(/\s+/g, ' ').trim() || '' +} diff --git a/plugins/airi-plugin-web-extension/src/shared/types.ts b/plugins/airi-plugin-web-extension/src/shared/types.ts new file mode 100644 index 000000000..9ae0282aa --- /dev/null +++ b/plugins/airi-plugin-web-extension/src/shared/types.ts @@ -0,0 +1,88 @@ +export type VideoSite = 'youtube' | 'bilibili' | 'unknown' + +export interface PageContextPayload { + site: VideoSite + url: string + title: string + description?: string + language?: string +} + +export interface VideoContextPayload { + site: VideoSite + url: string + title: string + channel?: string + videoId?: string + durationSec?: number + currentTimeSec?: number + isPlaying?: boolean + isMuted?: boolean + volume?: number + playbackRate?: number + isLive?: boolean + playerSize?: { width: number, height: number } +} + +export interface SubtitlePayload { + site: VideoSite + url: string + videoId?: string + title?: string + text: string + language?: string + startMs?: number + endMs?: number + isAuto?: boolean +} + +export interface VisionFramePayload { + site: VideoSite + url: string + videoId?: string + title?: string + capturedAt: number + width: number + height: number + dataUrl: string +} + +export type ContentToBackgroundMessage + = | { type: 'content:page', payload: PageContextPayload } + | { type: 'content:video', payload: VideoContextPayload } + | { type: 'content:subtitle', payload: SubtitlePayload } + | { type: 'content:vision:frame', payload: VisionFramePayload } + +export interface ExtensionSettings { + wsUrl: string + token: string + enabled: boolean + sendPageContext: boolean + sendVideoContext: boolean + sendSubtitles: boolean + sendSparkNotify: boolean + enableVision: boolean +} + +export interface ExtensionStatus { + connected: boolean + lastError?: string + settings: ExtensionSettings + lastPage?: PageContextPayload + lastVideo?: VideoContextPayload + lastSubtitle?: SubtitlePayload + lastVisionFrameAt?: number +} + +export type PopupToBackgroundMessage + = | { type: 'popup:get-status' } + | { type: 'popup:update-settings', payload: Partial } + | { type: 'popup:toggle-enabled', payload: boolean } + | { type: 'popup:request-vision-frame' } + | { type: 'popup:clear-error' } + +export type BackgroundToPopupMessage + = | { type: 'background:status', payload: ExtensionStatus } + +export type BackgroundToContentMessage + = | { type: 'background:request-vision-frame' } diff --git a/plugins/airi-plugin-web-extension/wxt.config.ts b/plugins/airi-plugin-web-extension/wxt.config.ts index e992d8ba2..603e5c4d7 100644 --- a/plugins/airi-plugin-web-extension/wxt.config.ts +++ b/plugins/airi-plugin-web-extension/wxt.config.ts @@ -9,6 +9,20 @@ type VitePlugin = NonNullable[number] // See https://wxt.dev/api/config.html export default defineConfig({ modules: ['@wxt-dev/module-vue'], + manifest: { + name: 'AIRI Web Extension', + description: 'Capture web context (videos, pages, subtitles) for Project AIRI.', + permissions: ['storage', 'tabs'], + host_permissions: [ + '*://*.youtube.com/*', + '*://*.youtu.be/*', + '*://*.bilibili.com/*', + '*://*.b23.tv/*', + ], + action: { + default_title: 'AIRI Web Extension', + }, + }, vite: () => { return { plugins: [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e4abfe8e..66187cf9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,7 +67,7 @@ catalogs: specifier: ^3.0.3 version: 3.0.3 '@vueuse/core': - specifier: ^14.1.0 + specifier: 14.1.0 version: 14.1.0 '@xsai-ext/providers': specifier: ^0.4.0-beta.13 @@ -3001,16 +3001,34 @@ importers: plugins/airi-plugin-web-extension: dependencies: - vue: + '@vueuse/core': specifier: 'catalog:' - version: 3.5.26(typescript@5.9.3) + version: 14.1.0(vue@3.5.26(typescript@5.9.3)) devDependencies: + '@iconify-json/solar': + specifier: ^1.2.5 + version: 1.2.5 + '@iconify-json/svg-spinners': + specifier: ^1.2.4 + version: 1.2.4 + '@proj-airi/server-sdk': + specifier: workspace:^ + version: link:../../packages/server-sdk + '@proj-airi/ui': + specifier: workspace:^ + version: link:../../packages/ui '@unocss/reset': specifier: ^66.5.11 version: 66.5.11 '@wxt-dev/module-vue': specifier: ^1.0.3 - version: 1.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 1.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + nanoid: + specifier: ^5.1.6 + version: 5.1.6 + vue: + specifier: 'catalog:' + version: 3.5.26(typescript@5.9.3) vue-tsc: specifier: ^3.1.8 version: 3.2.1(typescript@5.9.3) @@ -22449,6 +22467,12 @@ snapshots: vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vue: 3.5.26(typescript@5.9.3) + '@vitejs/plugin-vue@6.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-beta.53 + vite: 8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vue: 3.5.26(typescript@5.9.3) + '@vitest/browser-playwright@4.0.16(bufferutil@4.1.0)(playwright@1.57.0)(utf-8-validate@5.0.10)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16)': dependencies: '@vitest/browser': 4.0.16(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.16) @@ -23148,9 +23172,9 @@ snapshots: '@types/filesystem': 0.0.36 '@types/har-format': 1.2.16 - '@wxt-dev/module-vue@1.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@wxt-dev/module-vue@1.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@vitejs/plugin-vue': 6.0.3(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3)) + '@vitejs/plugin-vue': 6.0.3(vite@8.0.0-beta.5(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3)) wxt: 0.20.13(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.54.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - vite diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 66661eb78..214fe8e2b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -43,7 +43,7 @@ catalog: '@types/hast': ^3.0.4 '@types/splitpanes': ^2.2.6 '@types/unist': ^3.0.3 - '@vueuse/core': ^14.1.0 + '@vueuse/core': 14.1.0 '@xsai-ext/providers': ^0.4.0-beta.13 '@xsai/embed': ^0.4.0-beta.13 '@xsai/generate-speech': 0.4.0-beta.13