@@ -2,9 +2,9 @@ import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
dts: true,
|
||||
entry: ['./src/index.ts'],
|
||||
publint: true,
|
||||
dts: true,
|
||||
unused: true,
|
||||
publint: true,
|
||||
},
|
||||
])
|
||||
|
||||
@@ -16,11 +16,11 @@ import { resolveComma, toArray } from './utils/general'
|
||||
interface Options {
|
||||
config?: string
|
||||
configLoader?: 'auto' | 'native' | 'unconfig'
|
||||
debug?: boolean | string | string[]
|
||||
env?: Record<string, string>
|
||||
failOnWarn?: boolean
|
||||
logLevel?: LogLevelString.Error | LogLevelString.Log | LogLevelString.Warning
|
||||
noConfig?: boolean
|
||||
debug?: boolean | string | string[]
|
||||
logLevel?: LogLevelString.Log | LogLevelString.Warning | LogLevelString.Error
|
||||
failOnWarn?: boolean
|
||||
env?: Record<string, string>
|
||||
quiet?: boolean
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ const cli = cac('airi-plugin-claude-code-cli')
|
||||
cli.help().version(version)
|
||||
|
||||
cli
|
||||
.command('send', 'Pass Claude Code hook event to Channel Server', { allowUnknownOptions: true, ignoreOptionDefaultValue: true })
|
||||
.command('send', 'Pass Claude Code hook event to Channel Server', { ignoreOptionDefaultValue: true, allowUnknownOptions: true })
|
||||
.option('-c, --config <filename>', 'Use a custom config file')
|
||||
.option('--config-loader <loader>', 'Config loader to use: auto, native, unconfig', { default: 'auto' })
|
||||
.option('--no-config', 'Disable config file')
|
||||
@@ -68,10 +68,10 @@ cli
|
||||
const hookEvent = JSON.parse(stdinInput) as HookInput
|
||||
|
||||
if (hookEvent.hook_event_name === 'UserPromptSubmit') {
|
||||
const channelServer = new Client({ autoConnect: false, name: 'proj-airi:plugin-claude-code' })
|
||||
const channelServer = new Client({ name: 'proj-airi:plugin-claude-code', autoConnect: false })
|
||||
await channelServer.connect()
|
||||
|
||||
channelServer.send({ data: { text: hookEvent.prompt }, type: 'input:text' })
|
||||
channelServer.send({ type: 'input:text', data: { text: hookEvent.prompt } })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,43 +1,8 @@
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
wait: number,
|
||||
): T {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
return function (this: any, ...args: any[]) {
|
||||
if (timeout)
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
timeout = undefined
|
||||
fn.apply(this, args)
|
||||
}, wait)
|
||||
} as T
|
||||
}
|
||||
|
||||
export function resolveComma<T extends string>(arr: T[]): T[] {
|
||||
return arr.flatMap(format => format.split(',') as T[])
|
||||
}
|
||||
|
||||
export function resolveRegex<T>(str: T): RegExp | T {
|
||||
if (
|
||||
typeof str === 'string'
|
||||
&& str.length > 2
|
||||
&& str[0] === '/'
|
||||
&& str.at(-1) === '/'
|
||||
) {
|
||||
return new RegExp(str.slice(1, -1))
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
export function slash(string: string): string {
|
||||
return string.replaceAll('\\', '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* https://github.com/rolldown/tsdown/blob/a7e267ab7f4e836e836dab5cecf029fc35fd1939/src/utils/general.ts
|
||||
*/
|
||||
export function toArray<T>(
|
||||
val: null | T | T[] | undefined,
|
||||
val: T | T[] | null | undefined,
|
||||
defaultValue?: T,
|
||||
): T[] {
|
||||
if (Array.isArray(val)) {
|
||||
@@ -53,11 +18,46 @@ export function toArray<T>(
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveComma<T extends string>(arr: T[]): T[] {
|
||||
return arr.flatMap(format => format.split(',') as T[])
|
||||
}
|
||||
|
||||
export function resolveRegex<T>(str: T): T | RegExp {
|
||||
if (
|
||||
typeof str === 'string'
|
||||
&& str.length > 2
|
||||
&& str[0] === '/'
|
||||
&& str.at(-1) === '/'
|
||||
) {
|
||||
return new RegExp(str.slice(1, -1))
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
wait: number,
|
||||
): T {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
return function (this: any, ...args: any[]) {
|
||||
if (timeout)
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
timeout = undefined
|
||||
fn.apply(this, args)
|
||||
}, wait)
|
||||
} as T
|
||||
}
|
||||
|
||||
export function slash(string: string): string {
|
||||
return string.replaceAll('\\', '/')
|
||||
}
|
||||
|
||||
export const noop = <T>(v: T): T => v
|
||||
|
||||
export function matchPattern(
|
||||
id: string,
|
||||
patterns: (RegExp | string)[],
|
||||
patterns: (string | RegExp)[],
|
||||
): boolean {
|
||||
return patterns.some((pattern) => {
|
||||
if (pattern instanceof RegExp) {
|
||||
|
||||
@@ -2,11 +2,11 @@ import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
dts: true,
|
||||
entry: ['./src/run.ts'],
|
||||
inlineOnly: [],
|
||||
platform: 'node',
|
||||
publint: true,
|
||||
dts: true,
|
||||
unused: true,
|
||||
publint: true,
|
||||
},
|
||||
])
|
||||
|
||||
@@ -2,9 +2,9 @@ import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
dts: true,
|
||||
entry: ['./src/index.ts'],
|
||||
publint: true,
|
||||
dts: true,
|
||||
unused: true,
|
||||
publint: true,
|
||||
},
|
||||
])
|
||||
|
||||
@@ -35,10 +35,30 @@ let lastStatusSentAt = 0
|
||||
let connectionKey = ''
|
||||
let eventaContext: ReturnType<typeof createRuntimeEventaContext>['context'] | undefined
|
||||
|
||||
function buildNotifyKey(payload: { title?: string, url: string, videoId?: string }) {
|
||||
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)
|
||||
@@ -48,6 +68,18 @@ function emitStatus() {
|
||||
eventaContext?.emit(backgroundStatusChanged, toStatus(state, settings))
|
||||
}
|
||||
|
||||
async function updateSettings(partial: Partial<ExtensionSettings>) {
|
||||
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': {
|
||||
@@ -59,15 +91,6 @@ function handleContentMessage(message: ContentToBackgroundMessage) {
|
||||
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:video': {
|
||||
const payload = {
|
||||
...message.payload,
|
||||
@@ -77,6 +100,15 @@ function handleContentMessage(message: ContentToBackgroundMessage) {
|
||||
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()
|
||||
@@ -85,38 +117,6 @@ function handleContentMessage(message: ContentToBackgroundMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
settings = await loadSettings()
|
||||
await refreshClient()
|
||||
emitStatus()
|
||||
}
|
||||
|
||||
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 shouldNotifyVideo(payload: { title?: string, url: string, videoId?: string }) {
|
||||
const key = buildNotifyKey(payload)
|
||||
if (!key || key === lastVideoNotifyKey)
|
||||
return false
|
||||
lastVideoNotifyKey = key
|
||||
return true
|
||||
}
|
||||
|
||||
async function updateSettings(partial: Partial<ExtensionSettings>) {
|
||||
settings = await saveSettings(partial)
|
||||
await refreshClient()
|
||||
emitStatus()
|
||||
}
|
||||
|
||||
export default defineBackground(() => {
|
||||
const { context } = createRuntimeEventaContext()
|
||||
eventaContext = context
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { startContentObserver } from '../src/content'
|
||||
|
||||
export default defineContentScript({
|
||||
main() {
|
||||
startContentObserver()
|
||||
},
|
||||
matches: [
|
||||
'*://*/*',
|
||||
],
|
||||
runAt: 'document_idle',
|
||||
main() {
|
||||
startContentObserver()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -13,14 +13,14 @@ export const usePopupStore = createGlobalState(() => {
|
||||
const initialized = ref(false)
|
||||
|
||||
const form = reactive<ExtensionSettings>({
|
||||
enabled: true,
|
||||
enableVision: false,
|
||||
sendPageContext: true,
|
||||
sendSparkNotify: true,
|
||||
sendSubtitles: true,
|
||||
sendVideoContext: true,
|
||||
token: '',
|
||||
wsUrl: '',
|
||||
token: '',
|
||||
enabled: true,
|
||||
sendPageContext: true,
|
||||
sendVideoContext: true,
|
||||
sendSubtitles: true,
|
||||
sendSparkNotify: true,
|
||||
enableVision: false,
|
||||
})
|
||||
|
||||
const connected = computed(() => status.value?.connected ?? false)
|
||||
@@ -111,18 +111,18 @@ export const usePopupStore = createGlobalState(() => {
|
||||
}
|
||||
|
||||
return {
|
||||
applySettings,
|
||||
captureFrame,
|
||||
clearLastError,
|
||||
connected,
|
||||
form,
|
||||
init,
|
||||
lastError,
|
||||
lastSubtitle,
|
||||
lastVideo,
|
||||
refresh,
|
||||
status,
|
||||
syncing,
|
||||
form,
|
||||
connected,
|
||||
lastVideo,
|
||||
lastSubtitle,
|
||||
lastError,
|
||||
init,
|
||||
refresh,
|
||||
applySettings,
|
||||
toggle,
|
||||
captureFrame,
|
||||
clearLastError,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -16,8 +16,8 @@ export interface ClientState {
|
||||
connected: boolean
|
||||
lastError?: string
|
||||
lastPage?: PageContextPayload
|
||||
lastSubtitle?: SubtitlePayload
|
||||
lastVideo?: VideoContextPayload
|
||||
lastSubtitle?: SubtitlePayload
|
||||
lastVisionFrameAt?: number
|
||||
}
|
||||
|
||||
@@ -28,13 +28,30 @@ export function createClientState(): ClientState {
|
||||
}
|
||||
}
|
||||
|
||||
export function disconnectClient(state: ClientState) {
|
||||
if (!state.client)
|
||||
return
|
||||
function createIdentity() {
|
||||
return {
|
||||
kind: 'plugin',
|
||||
plugin: {
|
||||
id: PLUGIN_NAME,
|
||||
version: typeof packageJSON.version === 'string' ? packageJSON.version : undefined,
|
||||
},
|
||||
id: nanoid(),
|
||||
labels: {
|
||||
runtime: 'web-extension',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
state.client.close()
|
||||
state.client = null
|
||||
state.connected = false
|
||||
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) {
|
||||
@@ -48,20 +65,20 @@ export async function ensureClient(state: ClientState, settings: ExtensionSettin
|
||||
}
|
||||
|
||||
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,
|
||||
identity: createIdentity(),
|
||||
name: PLUGIN_NAME,
|
||||
onClose: () => {
|
||||
state.connected = false
|
||||
},
|
||||
onError: (error) => {
|
||||
state.connected = false
|
||||
state.lastError = errorMessageFromValue(error)
|
||||
},
|
||||
possibleEvents: ['context:update', 'spark:notify', 'spark:emit'],
|
||||
token: settings.token || undefined,
|
||||
url: settings.wsUrl,
|
||||
onClose: () => {
|
||||
state.connected = false
|
||||
},
|
||||
})
|
||||
|
||||
state.client = client
|
||||
@@ -77,6 +94,49 @@ export async function ensureClient(state: ClientState, settings: ExtensionSettin
|
||||
}
|
||||
}
|
||||
|
||||
export function disconnectClient(state: ClientState) {
|
||||
if (!state.client)
|
||||
return
|
||||
|
||||
state.client.close()
|
||||
state.client = null
|
||||
state.connected = false
|
||||
}
|
||||
|
||||
function sendContextUpdate(state: ClientState, update: Omit<ContextUpdate, 'id' | 'contextId'> & Partial<Pick<ContextUpdate, 'id' | 'contextId'>>) {
|
||||
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<string, unknown> }) {
|
||||
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
|
||||
|
||||
@@ -84,41 +144,17 @@ export function handlePageContext(state: ClientState, settings: ExtensionSetting
|
||||
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,
|
||||
site: payload.site,
|
||||
source: 'web-extension',
|
||||
title: payload.title,
|
||||
url: payload.url,
|
||||
},
|
||||
strategy: ContextUpdateStrategy.ReplaceSelf,
|
||||
text: `User is browsing: ${payload.title} (${payload.url}).`,
|
||||
})
|
||||
}
|
||||
|
||||
export function handleSubtitle(state: ClientState, settings: ExtensionSettings, payload: SubtitlePayload) {
|
||||
state.lastSubtitle = payload
|
||||
|
||||
if (!settings.enabled || !settings.sendSubtitles)
|
||||
return
|
||||
|
||||
sendContextUpdate(state, {
|
||||
lane: 'web:subtitle',
|
||||
metadata: {
|
||||
endMs: payload.endMs,
|
||||
isAuto: payload.isAuto,
|
||||
language: payload.language,
|
||||
site: payload.site,
|
||||
source: 'web-extension',
|
||||
startMs: payload.startMs,
|
||||
title: payload.title,
|
||||
url: payload.url,
|
||||
videoId: payload.videoId,
|
||||
},
|
||||
strategy: ContextUpdateStrategy.ReplaceSelf,
|
||||
text: `Subtitle: ${payload.text}`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -142,36 +178,22 @@ export function handleVideoContext(
|
||||
headline,
|
||||
note: payload.channel ? `Channel: ${payload.channel}` : undefined,
|
||||
payload: {
|
||||
channel: payload.channel,
|
||||
currentTimeSec: payload.currentTimeSec,
|
||||
durationSec: payload.durationSec,
|
||||
isLive: payload.isLive,
|
||||
isPlaying: payload.isPlaying,
|
||||
site: payload.site,
|
||||
title: payload.title,
|
||||
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, {
|
||||
lane: 'web:video',
|
||||
metadata: {
|
||||
channel: payload.channel,
|
||||
currentTimeSec: payload.currentTimeSec,
|
||||
durationSec: payload.durationSec,
|
||||
isLive: payload.isLive,
|
||||
isPlaying: payload.isPlaying,
|
||||
playbackRate: payload.playbackRate,
|
||||
playerSize: payload.playerSize,
|
||||
site: payload.site,
|
||||
source: 'web-extension',
|
||||
title: payload.title,
|
||||
url: payload.url,
|
||||
videoId: payload.videoId,
|
||||
},
|
||||
strategy: ContextUpdateStrategy.ReplaceSelf,
|
||||
lane: 'web:video',
|
||||
text: [
|
||||
headline,
|
||||
payload.channel ? `Channel: ${payload.channel}.` : undefined,
|
||||
@@ -180,65 +202,43 @@ export function handleVideoContext(
|
||||
: 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 toStatus(state: ClientState, settings: ExtensionSettings): ExtensionStatus {
|
||||
return {
|
||||
connected: state.connected,
|
||||
lastError: state.lastError,
|
||||
lastPage: state.lastPage,
|
||||
lastSubtitle: state.lastSubtitle,
|
||||
lastVideo: state.lastVideo,
|
||||
lastVisionFrameAt: state.lastVisionFrameAt,
|
||||
settings,
|
||||
}
|
||||
}
|
||||
export function handleSubtitle(state: ClientState, settings: ExtensionSettings, payload: SubtitlePayload) {
|
||||
state.lastSubtitle = payload
|
||||
|
||||
function createIdentity() {
|
||||
return {
|
||||
id: nanoid(),
|
||||
kind: 'plugin',
|
||||
labels: {
|
||||
runtime: 'web-extension',
|
||||
},
|
||||
plugin: {
|
||||
id: PLUGIN_NAME,
|
||||
version: typeof packageJSON.version === 'string' ? packageJSON.version : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function sendContextUpdate(state: ClientState, update: Omit<ContextUpdate, 'contextId' | 'id'> & Partial<Pick<ContextUpdate, 'contextId' | 'id'>>) {
|
||||
if (!state.client || !state.connected)
|
||||
if (!settings.enabled || !settings.sendSubtitles)
|
||||
return
|
||||
|
||||
const id = update.id ?? nanoid()
|
||||
state.client.send({
|
||||
data: {
|
||||
contextId: update.contextId ?? id,
|
||||
id,
|
||||
...update,
|
||||
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,
|
||||
},
|
||||
type: 'context:update',
|
||||
})
|
||||
}
|
||||
|
||||
function sendSparkNotify(state: ClientState, data: { headline: string, note?: string, payload?: Record<string, unknown> }) {
|
||||
if (!state.client || !state.connected)
|
||||
return
|
||||
|
||||
state.client.send({
|
||||
data: {
|
||||
destinations: ['character'],
|
||||
eventId: nanoid(),
|
||||
headline: data.headline,
|
||||
id: nanoid(),
|
||||
kind: 'ping',
|
||||
note: data.note,
|
||||
payload: data.payload,
|
||||
urgency: 'soon',
|
||||
},
|
||||
type: 'spark:notify',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,26 +8,14 @@ const SUBTITLE_DEDUPE_WINDOW = 2000
|
||||
|
||||
const lastPayloadByType = new Map<string, string>()
|
||||
|
||||
export function startContentObserver() {
|
||||
const site = detectSiteFromUrl(location.href)
|
||||
safeSend({ payload: buildPageContext(site), type: 'content:page' })
|
||||
const stopVideo = observeVideo(site)
|
||||
function safeSend(message: ContentToBackgroundMessage) {
|
||||
const serialized = JSON.stringify(message.payload)
|
||||
const lastSerialized = lastPayloadByType.get(message.type)
|
||||
if (serialized === lastSerialized)
|
||||
return
|
||||
|
||||
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({ payload: frame, type: 'content:vision:frame' })
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
stopVideo?.()
|
||||
}
|
||||
lastPayloadByType.set(message.type, serialized)
|
||||
void browser.runtime.sendMessage(message).catch(() => {})
|
||||
}
|
||||
|
||||
function buildPageContext(site: VideoSite): PageContextPayload {
|
||||
@@ -35,11 +23,11 @@ function buildPageContext(site: VideoSite): PageContextPayload {
|
||||
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,
|
||||
site,
|
||||
title: normalizeText(document.title),
|
||||
url: location.href,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,52 +41,39 @@ function buildVideoContext(site: VideoSite, video: HTMLVideoElement, includeProg
|
||||
const rect = video.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
channel: channel || undefined,
|
||||
currentTimeSec,
|
||||
durationSec,
|
||||
isMuted: video.muted,
|
||||
isPlaying: !video.paused && !video.ended,
|
||||
playbackRate: Number.isFinite(video.playbackRate) ? Number(video.playbackRate.toFixed(2)) : undefined,
|
||||
playerSize: rect.width && rect.height ? { height: Math.round(rect.height), width: Math.round(rect.width) } : undefined,
|
||||
site,
|
||||
title: title || normalizeText(document.title),
|
||||
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 captureVisionFrame(site: VideoSite, video: HTMLVideoElement): null | VisionFramePayload {
|
||||
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 {
|
||||
capturedAt: Date.now(),
|
||||
dataUrl: canvas.toDataURL('image/jpeg', 0.6),
|
||||
height,
|
||||
site,
|
||||
title: normalizeText(findVideoTitle(site)) || undefined,
|
||||
url: location.href,
|
||||
videoId: extractVideoId(site, location.href),
|
||||
width,
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
|
||||
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) {
|
||||
@@ -121,24 +96,54 @@ function findChannelName(site: VideoSite) {
|
||||
return 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
|
||||
)
|
||||
function observeTextTracks(site: VideoSite, video: HTMLVideoElement, onSubtitle: (payload: SubtitlePayload) => void) {
|
||||
const seen = new Map<string, number>()
|
||||
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (site === 'bilibili') {
|
||||
return (
|
||||
document.querySelector('h1.video-title')?.textContent
|
||||
|| document.querySelector('.video-title')?.textContent
|
||||
|| document.querySelector('h1')?.textContent
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return document.querySelector('h1')?.textContent
|
||||
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) {
|
||||
@@ -162,10 +167,10 @@ function observeSubtitleDom(site: VideoSite, onSubtitle: (payload: SubtitlePaylo
|
||||
lastText = text
|
||||
onSubtitle({
|
||||
site,
|
||||
text,
|
||||
title: normalizeText(findVideoTitle(site)) || undefined,
|
||||
url: location.href,
|
||||
title: normalizeText(findVideoTitle(site)) || undefined,
|
||||
videoId: extractVideoId(site, location.href),
|
||||
text,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -180,54 +185,37 @@ function observeSubtitleDom(site: VideoSite, onSubtitle: (payload: SubtitlePaylo
|
||||
}
|
||||
}
|
||||
|
||||
function observeTextTracks(site: VideoSite, video: HTMLVideoElement, onSubtitle: (payload: SubtitlePayload) => void) {
|
||||
const seen = new Map<string, number>()
|
||||
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)))
|
||||
|
||||
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
|
||||
if (!width || !height)
|
||||
return null
|
||||
|
||||
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
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
seen.set(key, now)
|
||||
onSubtitle({
|
||||
endMs: Math.floor(cue.endTime * 1000),
|
||||
language: (track.language || track.label || undefined),
|
||||
site,
|
||||
startMs: Math.floor(cue.startTime * 1000),
|
||||
text,
|
||||
title: normalizeText(findVideoTitle(site)) || undefined,
|
||||
url: location.href,
|
||||
videoId: extractVideoId(site, location.href),
|
||||
})
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
const attach = () => {
|
||||
const tracks = Array.from(video.textTracks ?? [])
|
||||
for (const track of tracks) {
|
||||
if (track.kind && !['captions', 'subtitles'].includes(track.kind))
|
||||
continue
|
||||
|
||||
if (track.mode === 'disabled')
|
||||
track.mode = 'hidden'
|
||||
track.oncuechange = () => handleCueChange(track)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
|
||||
attach()
|
||||
|
||||
const observer = new MutationObserver(() => attach())
|
||||
observer.observe(video, { attributes: true, childList: true, subtree: true })
|
||||
|
||||
return () => observer.disconnect()
|
||||
}
|
||||
|
||||
function observeVideo(site: VideoSite) {
|
||||
@@ -240,11 +228,11 @@ function observeVideo(site: VideoSite) {
|
||||
if (!video)
|
||||
return
|
||||
|
||||
safeSend({ payload: buildVideoContext(site, video, includeProgress), type: 'content:video' })
|
||||
safeSend({ type: 'content:video', payload: buildVideoContext(site, video, includeProgress) })
|
||||
}
|
||||
|
||||
const sendPage = () => {
|
||||
safeSend({ payload: buildPageContext(site), type: 'content:page' })
|
||||
safeSend({ type: 'content:page', payload: buildPageContext(site) })
|
||||
}
|
||||
|
||||
const onPlayback = () => sendVideo(true)
|
||||
@@ -265,8 +253,8 @@ function observeVideo(site: VideoSite) {
|
||||
stopTracks?.()
|
||||
stopDomSubtitles?.()
|
||||
|
||||
stopTracks = observeTextTracks(site, video, payload => safeSend({ payload, type: 'content:subtitle' }))
|
||||
stopDomSubtitles = observeSubtitleDom(site, payload => safeSend({ payload, type: 'content:subtitle' }))
|
||||
stopTracks = observeTextTracks(site, video, payload => safeSend({ type: 'content:subtitle', payload }))
|
||||
stopDomSubtitles = observeSubtitleDom(site, payload => safeSend({ type: 'content:subtitle', payload }))
|
||||
|
||||
sendPage()
|
||||
sendVideo(false)
|
||||
@@ -327,12 +315,24 @@ function observeVideo(site: VideoSite) {
|
||||
}
|
||||
}
|
||||
|
||||
function safeSend(message: ContentToBackgroundMessage) {
|
||||
const serialized = JSON.stringify(message.payload)
|
||||
const lastSerialized = lastPayloadByType.get(message.type)
|
||||
if (serialized === lastSerialized)
|
||||
return
|
||||
export function startContentObserver() {
|
||||
const site = detectSiteFromUrl(location.href)
|
||||
safeSend({ type: 'content:page', payload: buildPageContext(site) })
|
||||
const stopVideo = observeVideo(site)
|
||||
|
||||
lastPayloadByType.set(message.type, serialized)
|
||||
void browser.runtime.sendMessage(message).catch(() => {})
|
||||
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?.()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,14 @@ import type { ExtensionSettings } from './types'
|
||||
export const DEFAULT_WS_URL = 'ws://localhost:6121/ws'
|
||||
|
||||
export const DEFAULT_SETTINGS: ExtensionSettings = {
|
||||
enabled: true,
|
||||
enableVision: false,
|
||||
sendPageContext: true,
|
||||
sendSparkNotify: true,
|
||||
sendSubtitles: true,
|
||||
sendVideoContext: true,
|
||||
token: '',
|
||||
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'
|
||||
|
||||
@@ -9,9 +9,9 @@ const runtimeInstanceId = nanoid()
|
||||
interface EventaRuntimeMessage {
|
||||
__eventa: true
|
||||
channel: string
|
||||
detail?: unknown
|
||||
sourceId: string
|
||||
type?: string
|
||||
detail?: unknown
|
||||
}
|
||||
|
||||
type RuntimeEventListener = (event: Event) => void
|
||||
@@ -35,31 +35,31 @@ class RuntimeEventTarget implements EventTarget {
|
||||
this.listeners.get(type)?.set(listener, handler)
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: EventListenerOrEventListenerObject | null) {
|
||||
if (!listener)
|
||||
return
|
||||
|
||||
this.listeners.get(type)?.delete(listener)
|
||||
}
|
||||
|
||||
dispatchEvent(event: Event) {
|
||||
const detail = 'detail' in event ? (event as CustomEvent).detail : undefined
|
||||
this.send({
|
||||
__eventa: true,
|
||||
channel: EVENTA_RUNTIME_CHANNEL,
|
||||
detail,
|
||||
sourceId: runtimeInstanceId,
|
||||
type: event.type,
|
||||
detail,
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
emit(type: string, detail?: unknown) {
|
||||
const event = { detail, type } as CustomEvent
|
||||
const event = { type, detail } as CustomEvent
|
||||
for (const listener of this.listeners.get(type)?.values() ?? [])
|
||||
listener(event)
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: EventListenerOrEventListenerObject | null) {
|
||||
if (!listener)
|
||||
return
|
||||
|
||||
this.listeners.get(type)?.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function createRuntimeEventaContext() {
|
||||
@@ -68,8 +68,8 @@ export function createRuntimeEventaContext() {
|
||||
})
|
||||
|
||||
const { context, dispose } = createContext(eventTarget, {
|
||||
errorEventName: false,
|
||||
messageEventName: EVENTA_MESSAGE_EVENT,
|
||||
errorEventName: false,
|
||||
})
|
||||
|
||||
const runtimeListener = (message: unknown) => {
|
||||
|
||||
@@ -38,6 +38,6 @@ export function extractVideoId(site: VideoSite, url: string): string | undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function normalizeText(value: null | string | undefined) {
|
||||
export function normalizeText(value: string | null | undefined) {
|
||||
return value?.replace(/\s+/g, ' ').trim() || ''
|
||||
}
|
||||
|
||||
@@ -1,78 +1,78 @@
|
||||
export type BackgroundToContentMessage
|
||||
= | { type: 'background:request-vision-frame' }
|
||||
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
|
||||
= | { payload: PageContextPayload, type: 'content:page' }
|
||||
| { payload: SubtitlePayload, type: 'content:subtitle' }
|
||||
| { payload: VideoContextPayload, type: 'content:video' }
|
||||
| { payload: VisionFramePayload, type: 'content:vision:frame' }
|
||||
= | { type: 'content:page', payload: PageContextPayload }
|
||||
| { type: 'content:video', payload: VideoContextPayload }
|
||||
| { type: 'content:subtitle', payload: SubtitlePayload }
|
||||
| { type: 'content:vision:frame', payload: VisionFramePayload }
|
||||
|
||||
export interface ExtensionSettings {
|
||||
enabled: boolean
|
||||
enableVision: boolean
|
||||
sendPageContext: boolean
|
||||
sendSparkNotify: boolean
|
||||
sendSubtitles: boolean
|
||||
sendVideoContext: boolean
|
||||
token: string
|
||||
wsUrl: string
|
||||
token: string
|
||||
enabled: boolean
|
||||
sendPageContext: boolean
|
||||
sendVideoContext: boolean
|
||||
sendSubtitles: boolean
|
||||
sendSparkNotify: boolean
|
||||
enableVision: boolean
|
||||
}
|
||||
|
||||
export interface ExtensionStatus {
|
||||
connected: boolean
|
||||
lastError?: string
|
||||
lastPage?: PageContextPayload
|
||||
lastSubtitle?: SubtitlePayload
|
||||
lastVideo?: VideoContextPayload
|
||||
lastVisionFrameAt?: number
|
||||
settings: ExtensionSettings
|
||||
lastPage?: PageContextPayload
|
||||
lastVideo?: VideoContextPayload
|
||||
lastSubtitle?: SubtitlePayload
|
||||
lastVisionFrameAt?: number
|
||||
}
|
||||
|
||||
export interface PageContextPayload {
|
||||
description?: string
|
||||
language?: string
|
||||
site: VideoSite
|
||||
title: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface SubtitlePayload {
|
||||
endMs?: number
|
||||
isAuto?: boolean
|
||||
language?: string
|
||||
site: VideoSite
|
||||
startMs?: number
|
||||
text: string
|
||||
title?: string
|
||||
url: string
|
||||
videoId?: string
|
||||
}
|
||||
|
||||
export interface VideoContextPayload {
|
||||
channel?: string
|
||||
currentTimeSec?: number
|
||||
durationSec?: number
|
||||
isLive?: boolean
|
||||
isMuted?: boolean
|
||||
isPlaying?: boolean
|
||||
playbackRate?: number
|
||||
playerSize?: { height: number, width: number }
|
||||
site: VideoSite
|
||||
title: string
|
||||
url: string
|
||||
videoId?: string
|
||||
volume?: number
|
||||
}
|
||||
|
||||
export type VideoSite = 'bilibili' | 'unknown' | 'youtube'
|
||||
|
||||
export interface VisionFramePayload {
|
||||
capturedAt: number
|
||||
dataUrl: string
|
||||
height: number
|
||||
site: VideoSite
|
||||
title?: string
|
||||
url: string
|
||||
videoId?: string
|
||||
width: number
|
||||
}
|
||||
export type BackgroundToContentMessage
|
||||
= | { type: 'background:request-vision-frame' }
|
||||
|
||||
@@ -8,18 +8,18 @@ type VitePlugin = NonNullable<WxtViteConfig['plugins']>[number]
|
||||
|
||||
// See https://wxt.dev/api/config.html
|
||||
export default defineConfig({
|
||||
modules: ['@wxt-dev/module-vue'],
|
||||
manifest: {
|
||||
action: {
|
||||
default_title: 'AIRI Web Extension',
|
||||
},
|
||||
description: 'Capture web context (videos, pages, subtitles) for Project AIRI.',
|
||||
name: 'AIRI Web Extension',
|
||||
description: 'Capture web context (videos, pages, subtitles) for Project AIRI.',
|
||||
permissions: ['storage', 'tabs'],
|
||||
optional_host_permissions: [
|
||||
'*://*/*',
|
||||
],
|
||||
permissions: ['storage', 'tabs'],
|
||||
action: {
|
||||
default_title: 'AIRI Web Extension',
|
||||
},
|
||||
},
|
||||
modules: ['@wxt-dev/module-vue'],
|
||||
vite: () => {
|
||||
return {
|
||||
plugins: [
|
||||
|
||||
Reference in New Issue
Block a user