Commit 2bc86acf authored by Domi's avatar Domi

feat: chat docs addon

parent 54a246cc
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface DocumentPictureInPicture extends EventTarget { interface DocumentPictureInPicture extends EventTarget {
window: Window | null; window: Window | null
requestWindow(option?: { width?: number; height?: number }): Promise<Window>; requestWindow(option?: { width?: number; height?: number }): Promise<Window>
} }
export declare global { export declare global {
interface documentPictureInPicture extends DocumentPictureInPicture {} interface documentPictureInPicture extends DocumentPictureInPicture {}
const __DEV__: boolean
interface Window { interface Window {
documentPictureInPicture: DocumentPictureInPicture; documentPictureInPicture: DocumentPictureInPicture
trustedTypes: any; trustedTypes: any
} }
interface Navigator { interface Navigator {
userAgentData: { userAgentData: {
platform: string; platform: string
}; }
} }
} }
export {}; export {}
import * as esbuild from "esbuild"; import * as esbuild from "esbuild"
const isWatch = process.argv.includes("--watch"); const isWatch = process.argv.includes("--watch")
const ctx = await esbuild.context({ const ctx = await esbuild.context({
entryPoints: { entryPoints: {
main: "./src/content/main.ts",
bg: "./src/bg/index.ts", bg: "./src/bg/index.ts",
"js/content-main": "./src/content/main.ts",
"js/pdf.worker": "./src/assets/pdf.worker.js",
}, },
bundle: true, bundle: true,
format: "iife", format: "iife",
...@@ -12,11 +13,11 @@ const ctx = await esbuild.context({ ...@@ -12,11 +13,11 @@ const ctx = await esbuild.context({
alias: { alias: {
"@": "./src/", "@": "./src/",
}, },
}); })
if (isWatch) { if (isWatch) {
await ctx.watch(); await ctx.watch()
} else { } else {
await ctx.rebuild(); await ctx.rebuild()
ctx.dispose(); ctx.dispose()
} }
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -14,10 +14,17 @@ ...@@ -14,10 +14,17 @@
"build:js": "node esbuild.mjs", "build:js": "node esbuild.mjs",
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false", "type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
"zip:win": "7z a anything-copilot.zip .\\dist\\*", "zip:win": "7z a anything-copilot.zip .\\dist\\*",
"zip": "" "zip": "",
"test": "vitest"
}, },
"dependencies": { "dependencies": {
"@types/turndown": "^5.0.4",
"buffer": "^6.0.3",
"file-type": "^18.7.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"mammoth": "^1.6.0",
"pdfjs-dist": "^4.0.269",
"turndown": "^7.1.2",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-i18n": "^9.7.0" "vue-i18n": "^9.7.0"
}, },
...@@ -37,6 +44,7 @@ ...@@ -37,6 +44,7 @@
"tailwindcss": "^3.3.5", "tailwindcss": "^3.3.5",
"typescript": "~5.2.0", "typescript": "~5.2.0",
"vite": "^4.4.11", "vite": "^4.4.11",
"vitest": "^1.1.0",
"vue-tsc": "^1.8.19" "vue-tsc": "^1.8.19"
} }
} }
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Anything Copilot</title> <title>Anything Copilot</title>
<base href="http://localhost:3000"> <base href="http://localhost:3000">
</head> </head>
......
public/favicon.ico

1.91 KB | W: | H:

public/favicon.ico

160 KB | W: | H:

public/favicon.ico
public/favicon.ico
public/favicon.ico
public/favicon.ico
  • 2-up
  • Swipe
  • Onion skin
public/logo.png

1.6 KB | W: | H:

public/logo.png

2.05 KB | W: | H:

public/logo.png
public/logo.png
public/logo.png
public/logo.png
  • 2-up
  • Swipe
  • Onion skin
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="75" y="12" width="232" height="58" rx="8" transform="rotate(90 75 12)" fill="#3BABFD"/> <rect x="76" y="12" width="232" height="58" rx="8" transform="rotate(90 76 12)" fill="#FF0000"/>
<rect x="157" y="12" width="232" height="59" rx="8" transform="rotate(90 157 12)" fill="#0087E8"/> <path d="M76 158.16L76 236C76 240.418 72.4183 244 68 244L26 244C21.5817 244 18 240.418 18 236L18 197.02L76 158.16Z" fill="#FF5C00"/>
<rect x="238" y="12" width="232" height="58" rx="8" transform="rotate(90 238 12)" fill="#005592"/> <rect x="157.2" y="12" width="232" height="58" rx="8" transform="rotate(90 157.2 12)" fill="#FF5C00"/>
<path d="M157.2 103.64L157.2 236C157.2 240.418 153.618 244 149.2 244L107.2 244C102.782 244 99.2 240.418 99.2 236L99.2 142.5L157.2 103.64Z" fill="#FF9900"/>
<rect x="238" y="12" width="232" height="58" rx="8" transform="rotate(90 238 12)" fill="#FF9900"/>
<path d="M238 49.12L238 236C238 240.418 234.418 244 230 244L188 244C183.582 244 180 240.418 180 236L180 87.98L238 49.12Z" fill="#FFC700"/>
</svg> </svg>
...@@ -42,6 +42,24 @@ ...@@ -42,6 +42,24 @@
--bg-rgb: 255, 255, 255; --bg-rgb: 255, 255, 255;
--fg-rgb: 0, 0, 0; --fg-rgb: 0, 0, 0;
--bg-hue: 200;
--bg-s: 5%;
--bg-l: 100%;
--bg-soft-l: 96%;
--bg-mute-l: 90%;
--fg-hue: 200;
--fg-s: 0%;
--fg-l: 0%;
--primary-hue: 36;
--primary-s: 100%;
--primary-l: 50%;
--primary-hsl: var(--primary-hue) var(--primary-s) var(--primary-l);
--bg-hsl: var(--bg-hue) var(--bg-s) var(--bg-l);
--fg-hsl: var(--fg-hue) var(--fg-s) var(--fg-l);
--section-gap: 160px; --section-gap: 160px;
} }
...@@ -60,6 +78,21 @@ ...@@ -60,6 +78,21 @@
--bg-rgb: 0, 0, 0; --bg-rgb: 0, 0, 0;
--fg-rgb: 255, 255, 255; --fg-rgb: 255, 255, 255;
--bg-hue: 200;
--bg-s: 0%;
--bg-l: 10%;
--bg-soft-l: 14%;
--bg-mute-l: 20%;
--fg-hue: 200;
--fg-s: 0%;
--fg-l: 100%;
}
input,
textarea {
color-scheme: dark;
} }
} }
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
@import "./base.css"; @import "./base.css";
@import "./utilities.css";
#app { #app {
margin: 0 auto; margin: 0 auto;
...@@ -31,3 +32,17 @@ a, ...@@ -31,3 +32,17 @@ a,
padding: 0 2rem; padding: 0 2rem;
} */ } */
} }
.scrollbar::-webkit-scrollbar {
background: transparent;
width: 12px;
}
.scrollbar::-webkit-scrollbar-thumb {
background: transparent;
transition: all 0.3s;
}
.scrollbar:hover::-webkit-scrollbar-thumb {
border: 4px solid transparent;
background: rgba(var(--fg-rgb), 0.1);
background-clip: content-box;
}
import "pdfjs-dist/build/pdf.worker.mjs"
@layer utilities {
}
import { MessageType } from "@/types"; import {
import { waitMessage, tabUpdated } from "@/utils/ext"; MessageType,
ServiceFunc,
type ParseDocOptions,
type InvokeMessage,
} from "@/types"
import { waitMessage, tabUpdated } from "@/utils/ext"
import { offscreen } from "./offscreen"
async function openPipBackground(url: string) { async function openPipBackground(url: string) {
const tab = await chrome.tabs.create({ const tab = await chrome.tabs.create({
url: url, url: url,
}); })
await tabUpdated({ tabId: tab.id!, status: "complete" }); await tabUpdated({ tabId: tab.id!, status: "complete" })
chrome.tabs.sendMessage(tab.id!, { chrome.tabs.sendMessage(tab.id!, {
type: "pip", type: "pip",
...@@ -14,12 +20,13 @@ async function openPipBackground(url: string) { ...@@ -14,12 +20,13 @@ async function openPipBackground(url: string) {
url: url, url: url,
mode: "write-html", mode: "write-html",
}, },
}); })
} }
/** @deprecated */
async function getContentCss(id: number, url: string) { async function getContentCss(id: number, url: string) {
const res = await fetch(url); const res = await fetch(url)
const text = await res.text(); const text = await res.text()
chrome.tabs.sendMessage(id, { chrome.tabs.sendMessage(id, {
type: "content-css", type: "content-css",
...@@ -27,107 +34,150 @@ async function getContentCss(id: number, url: string) { ...@@ -27,107 +34,150 @@ async function getContentCss(id: number, url: string) {
url: url, url: url,
value: text, value: text,
}, },
}); })
} }
async function pipLaunch(url: string) { async function pipLaunch(url: string) {
const tab = await chrome.tabs.create({ url }); const tab = await chrome.tabs.create({ url })
await waitMessage({ await waitMessage({
tabId: tab.id!, tabId: tab.id!,
type: MessageType.contentMount, type: MessageType.contentMount,
}); })
chrome.tabs.sendMessage(tab.id!, { chrome.tabs.sendMessage(tab.id!, {
type: MessageType.pipLaunch, type: MessageType.pipLaunch,
url: url, url: url,
}); })
} }
type QueryOptions = { type QueryOptions = {
windowId?: number; windowId?: number
width?: number; width?: number
height?: number; height?: number
}; }
async function getPipWindow( async function getPipWindow(
id: number, id: number,
{ windowId, width, height }: QueryOptions { windowId, width, height }: QueryOptions
) { ) {
if (windowId) { if (windowId) {
const win = await chrome.windows.get(windowId); const win = await chrome.windows.get(windowId)
chrome.tabs.sendMessage(id, { chrome.tabs.sendMessage(id, {
type: MessageType.pipWinInfo, type: MessageType.pipWinInfo,
window: win, window: win,
}); })
return win; return win
} }
const windows = await chrome.windows.getAll({}); const windows = await chrome.windows.getAll({})
const win = windows.find((w) => w.width === width && w.height === height); const win = windows.find((w) => w.width === width && w.height === height)
chrome.tabs.sendMessage(id, { chrome.tabs.sendMessage(id, {
type: MessageType.pipWinInfo, type: MessageType.pipWinInfo,
window: win, window: win,
}); })
return win; return win
} }
type MinimizeOptions = { type MinimizeOptions = {
windowId: number; windowId: number
}; }
async function minimizePip({ windowId }: MinimizeOptions) { async function minimizePip({ windowId }: MinimizeOptions) {
await chrome.windows.update(windowId, { state: "minimized" }); await chrome.windows.update(windowId, { state: "minimized" })
} }
type UpdatePipWinOption = { type UpdatePipWinOption = {
windowId: number; windowId: number
windowInfo: Partial<chrome.windows.UpdateInfo>; windowInfo: Partial<chrome.windows.UpdateInfo>
}; }
async function updateWindow({ windowId, windowInfo }: UpdatePipWinOption) { async function updateWindow({ windowId, windowInfo }: UpdatePipWinOption) {
await chrome.windows.update(windowId, windowInfo); await chrome.windows.update(windowId, windowInfo)
}
async function parseDoc(
options: ParseDocOptions,
sender: chrome.runtime.MessageSender
) {
const result = await offscreen.parseDoc(options)
chrome.tabs.sendMessage(sender.tab?.id!, {
// type: MessageType.parseDoc
})
}
async function handleInvokeRequest(
message: any,
sender: chrome.runtime.MessageSender
) {
const { key, func, args } = message as InvokeMessage
let result = null
let error = null
try {
switch (func) {
case ServiceFunc.parseDoc:
result = await offscreen.parseDoc(args[0])
break
}
} catch (err) {
error = err
}
chrome.tabs.sendMessage(sender.tab?.id!, {
type: MessageType.invokeResponse,
key,
success: !error,
payload: !error ? result : error,
})
} }
function handleMessage(message: any, sender: chrome.runtime.MessageSender) { function handleMessage(message: any, sender: chrome.runtime.MessageSender) {
console.log("bg message: ", message, sender, Date.now()); console.log("bg message: ", message, sender, Date.now())
switch (message?.type) { switch (message?.type) {
case MessageType.bgOpenPip: case MessageType.bgOpenPip:
openPipBackground(message.url); openPipBackground(message.url)
break; break
case "get-content-css": case "get-content-css":
getContentCss(sender.tab?.id || 0, message.url); getContentCss(sender.tab?.id || 0, message.url)
break; break
case MessageType.bgPipLaunch: case MessageType.bgPipLaunch:
pipLaunch(message.url); pipLaunch(message.url)
break; break
case MessageType.getPipWinInfo: case MessageType.getPipWinInfo:
getPipWindow(sender.tab?.id!, message.options); getPipWindow(sender.tab?.id!, message.options)
break; break
case MessageType.updateWindow: case MessageType.updateWindow:
updateWindow(message.options); updateWindow(message.options)
break; break
case MessageType.removeWindow: case MessageType.removeWindow:
chrome.windows.remove(message.options.windowId); chrome.windows.remove(message.options.windowId)
break; break
case MessageType.setupOffscreenDocument:
return offscreen.setup()
case MessageType.fromOffscreen:
return offscreen.handleOffscreenMessage(message)
case MessageType.invokeRequest:
handleInvokeRequest(message, sender)
break
} }
} }
chrome.runtime.onMessage.addListener(handleMessage); chrome.runtime.onMessage.addListener(handleMessage)
async function handleToggleMinimize() { async function handleToggleMinimize() {
const { pipWindowId } = await chrome.storage.local.get({ pipWindowId: null }); const { pipWindowId } = await chrome.storage.local.get({ pipWindowId: null })
if (!pipWindowId) return; if (!pipWindowId) return
const windowInfo = await chrome.windows.get(pipWindowId); const windowInfo = await chrome.windows.get(pipWindowId)
if (!windowInfo) return; if (!windowInfo) return
await chrome.windows.update(pipWindowId, { await chrome.windows.update(pipWindowId, {
state: windowInfo.state == "minimized" ? "normal" : "minimized", state: windowInfo.state == "minimized" ? "normal" : "minimized",
}); })
} }
function handleCommand(command: string) { function handleCommand(command: string) {
console.log("command: ", command); console.log("command: ", command)
switch (command) { switch (command) {
case "toggleMinimize": case "toggleMinimize":
handleToggleMinimize(); handleToggleMinimize()
break; break
} }
} }
chrome.commands.onCommand.addListener(handleCommand); chrome.commands.onCommand.addListener(handleCommand)
import { MessageType, ServiceFunc, type ParseDocOptions } from "@/types"
let creating: Promise<void> | null // A global promise to avoid concurrency issues
export async function setupOffscreenDocument(path: string) {
// Check all windows controlled by the service worker to see if one
// of them is the offscreen document with the given path
const offscreenUrl = chrome.runtime.getURL(path)
const existingContexts = await (chrome.runtime as any).getContexts({
contextTypes: ["OFFSCREEN_DOCUMENT"],
documentUrls: [offscreenUrl],
})
if (existingContexts.length > 0) {
return
}
// create offscreen document
if (creating) {
await creating
} else {
creating = chrome.offscreen.createDocument({
url: path,
reasons: [
chrome.offscreen.Reason.BLOBS,
chrome.offscreen.Reason.WORKERS,
chrome.offscreen.Reason.DOM_PARSER,
// chrome.offscreen.Reason.DOM_SCRAPING,
// chrome.offscreen.Reason.IFRAME_SCRIPTING,
],
justification: "reason for needing the document",
})
await creating
creating = null
}
}
export const offscreenHtmlPath = "/src/pages/offscreen.html"
class Offscreen {
public readonly path: string
private pendingCallback: {
[key in string]: { resolve: (v: any) => void; reject: (e: any) => void }
}
constructor(path: string) {
this.path = path
this.pendingCallback = {}
}
public setup() {
return setupOffscreenDocument(this.path)
}
public handleOffscreenMessage(message: any) {
const { type, key, payload, success } = message
if (type === MessageType.fromOffscreen) {
const callbacks = this.pendingCallback[key]
if (callbacks) {
const callback = success != false ? callbacks.resolve : callbacks.reject
callback(payload)
}
delete this.pendingCallback[key]
}
}
private getReturnValue(key: string) {
const promise = new Promise((resolve, reject) => {
this.pendingCallback[key] = { resolve, reject }
})
return promise
}
private async executeTask(task: string, payload: any) {
await this.setup()
const key = crypto.randomUUID()
chrome.runtime.sendMessage({
type: MessageType.toOffscreen,
key,
task,
payload,
})
return key
}
public async parseDoc(options: ParseDocOptions) {
console.log("to offscreen parseDoc ", options)
const key = await this.executeTask(ServiceFunc.parseDoc, options)
return this.getReturnValue(key)
}
}
export const offscreen = new Offscreen(offscreenHtmlPath)
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue" import { ref, computed, watch, onMounted, onUnmounted } from "vue"
import IconMinimize from "@/components/icons/IconMinimize.vue" import IconMinimize from "@/components/icons/IconMinimize.vue"
import IconSplitRight from "@/components/icons/IconSplitscreenRight.vue" import IconSplitRight from "@/components/icons/IconSplitscreenRight.vue"
import IconClose from "@/components/icons/IconClose.vue" import IconClose from "@/components/icons/IconClose.vue"
...@@ -8,7 +8,6 @@ import { pipWindow } from "@/store" ...@@ -8,7 +8,6 @@ import { pipWindow } from "@/store"
import { throttle } from "lodash-es" import { throttle } from "lodash-es"
import IconRefresh from "./icons/IconRefresh.vue" import IconRefresh from "./icons/IconRefresh.vue"
import { dispatchContentEvent } from "@/content/event" import { dispatchContentEvent } from "@/content/event"
import { onUnmounted } from "vue"
import { useI18n } from "@/utils/i18n" import { useI18n } from "@/utils/i18n"
import IconHide from "./icons/IconHide.vue" import IconHide from "./icons/IconHide.vue"
...@@ -71,11 +70,11 @@ watch(open, (value, oldValue, onCleanup) => { ...@@ -71,11 +70,11 @@ watch(open, (value, oldValue, onCleanup) => {
const menuPointerEnter = () => { const menuPointerEnter = () => {
clearTimeout(timer.value) clearTimeout(timer.value)
timer.value = setTimeout(() => (open.value = true), 200) timer.value = window.setTimeout(() => (open.value = true), 200)
} }
const menuPointerLeave = () => { const menuPointerLeave = () => {
clearTimeout(timer.value) clearTimeout(timer.value)
timer.value = setTimeout(() => (open.value = false), 200) timer.value = window.setTimeout(() => (open.value = false), 200)
} }
const btnPointerEnter = () => { const btnPointerEnter = () => {
...@@ -86,7 +85,7 @@ const btnPointerLeave = () => { ...@@ -86,7 +85,7 @@ const btnPointerLeave = () => {
if (pinOpen.value) { if (pinOpen.value) {
return return
} }
timer.value = setTimeout(() => (open.value = false), 350) timer.value = window.setTimeout(() => (open.value = false), 350)
} }
const minimize = () => { const minimize = () => {
......
<script setup lang="ts">
const props = defineProps<{
class?: string
fade?: boolean
}>()
</script>
<template>
<div :class="['scrollbar relative overflow-auto', props.class]">
<div
v-if="fade == true"
class="fade sticky top-0 left-0 w-full h-4 z-50"
></div>
<slot></slot>
<div
v-if="fade == true"
class="fade sticky bottom-0 left-0 w-full h-4 z-50"
></div>
</div>
</template>
<style scoped>
.scrollbar::-webkit-scrollbar {
background: transparent;
width: 12px;
}
.scrollbar::-webkit-scrollbar-thumb {
background: transparent;
transition: all 0.3s;
}
.scrollbar:hover::-webkit-scrollbar-thumb {
border: 4px solid transparent;
background: rgba(var(--fg-rgb), 0.1);
background-clip: content-box;
}
.fade {
--fade-bg-color: var(--color-background);
}
.fade.top-0 {
background: linear-gradient(to bottom, var(--fade-bg-color), transparent);
}
.fade.bottom-0 {
background: linear-gradient(to bottom, transparent, var(--fade-bg-color));
}
</style>
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref } from "vue"
import IconNoteStackAdd from "@/components/icons/IconNoteStackAdd.vue"
import { chatDocsPanel, docsAddon } from "@/store"
import ChatDocsPanel from "@/components/chatdocs/ChatDocsPanel.vue"
const logoUrl = chrome.runtime.getURL("/logo.svg")
const div = ref<HTMLDivElement>()
let timer = 0
function onDragOver(e: DragEvent) {
docsAddon.visible = true
clearTimeout(timer)
timer = window.setTimeout(() => {
docsAddon.visible = false
}, 180)
console.log(e.dataTransfer?.items.length, e.dataTransfer?.items[0]?.type)
}
async function onDrop(e: DragEvent) {
e.preventDefault()
docsAddon.active = false
docsAddon.visible = false
if (e.dataTransfer) {
const items: typeof chatDocsPanel.inputs = []
for (let i = 0; i < e.dataTransfer.items.length; i++) {
const item = e.dataTransfer.items[i]
if (item.kind == "file") {
const file = item.getAsFile()
if (file) {
items.push({
key: crypto.randomUUID(),
kind: item.kind,
type: item.type,
data: file,
})
}
}
if (item.kind == "string") {
const { kind, type } = item
const data = await new Promise<string>((r) => item.getAsString(r))
items.push({
key: crypto.randomUUID(),
kind,
type,
data,
})
}
}
// dropZone.items = items
chatDocsPanel.visible = true
chatDocsPanel.inputs = items
console.log("items _> ", items)
}
const files = e.dataTransfer?.files
const items = e.dataTransfer?.items
const types = e.dataTransfer?.types
console.log("files: ", files, files?.length)
console.log("items: ", items, items?.length)
console.log("types: ", types, items?.length)
}
onMounted(() => {
const doc = div.value?.ownerDocument || document
doc.addEventListener("dragover", onDragOver, true)
})
onUnmounted(() => {
const doc = div.value?.ownerDocument || document
doc.removeEventListener("dragover", onDragOver, true)
})
</script>
<template>
<div ref="div" class="hidden"></div>
<div
v-if="docsAddon.visible"
:class="[
'fixed mt-10 top-0 p-6 border-2 rounded-lg bg-background z-[9999999]',
'shadow-lg left-1/2 -translate-x-1/2 w-max transition-all',
{
'border-primary/50': !docsAddon.active && docsAddon.visible,
'border-primary scale-105': docsAddon.active,
},
]"
@dragenter="docsAddon.active = true"
@dragleave="docsAddon.active = false"
@dragover="(e) => e.preventDefault()"
@drop="onDrop"
>
<div class="pointer-events-none">
<div class="flex items-center gap-2">
<IconNoteStackAdd class="w-8 h-8" />
<div class="text-xl font-bold">Anything Copilot</div>
</div>
<p class="text-center text-sm mt-2">支持PDF、DOCX</p>
<div :class="['absolute text-xs flex items-center bottom-2 right-2']">
<img :src="logoUrl" class="w-3 h-3" />
</div>
</div>
</div>
<div
v-if="chatDocsPanel.visible"
:class="[
'fixed bottom-10 left-1/2 top-1/2 p-3 w-80 border rounded-lg z-[9999]',
'border-foreground/10 bg-background -translate-x-1/2 -translate-y-1/2',
'h-fit shadow-lg dark:border-2',
]"
>
<ChatDocsPanel @close="chatDocsPanel.visible = false" />
</div>
</template>
<style scoped></style>
<script setup lang="ts">
import { watch, computed, reactive, ref, onMounted } from "vue"
import { chatDocsPanel } from "@/store"
import IconClose from "@/components/icons/IconClose.vue"
import { contentService } from "@/utils/service"
import { convertBlobToBase64, semanticClip } from "@/utils/utils"
import ScrollView from "@/components/ScrollView.vue"
import IconArrowBack from "@/components/icons/IconArrowBack.vue"
import IconArrowRight from "../icons/IconArrowRight.vue"
import DocItem from "./DocItem.vue"
import DocInput from "./DocInput.vue"
import IconPlayCircle from "../icons/IconPlayCircle.vue"
import IconPause from "../icons/IconPause.vue"
import IconProgressActivity from "../icons/IconProgressActivity.vue"
import { sitesConfig } from "./chat"
import { query, dispatchInput, click, waitFor } from "@/utils/dom"
type Sheet = "" | "docSelect" | "promptTemplate"
type SnippetItem = {
key: string
index: number
start: number
end: number
snippet: string
}
type Selector = {
input: string
send: string
wait: string
}
defineEmits(["close"])
const logoUrl = chrome.runtime.getURL("/logo.svg")
const div = ref<HTMLDivElement>()
const sheet = ref<Sheet>("")
const currentDoc = ref("")
const sendTask = reactive({
key: "",
status: "" as "" | "running" | "done",
})
const config = reactive({
prompt: `Here is some relevant reference information. Please refrain from summarizing or responding to specific content until I pose targeted questions. If you comprehend this instruction, kindly reply with "Okay, please continue."`,
maxInputLength: 3000,
maxRuns: 10,
selector: null as Selector | null,
})
const docs = computed(() => {
const docs = Object.values(chatDocsPanel.docMap).filter(
(doc) => !doc.removed && doc.success
)
return docs
})
const currentMessage = computed(() => {
const { prompt, maxInputLength, maxRuns } = config
const snippets: SnippetItem[] = []
for (let doc of docs.value) {
for (let i = 0; i < doc.contents.length; i++) {
const content = doc.contents[i]
if (!content.selected || content.sentLength == content.data.length) {
continue
}
const snippetsLength = snippets.reduce((a, c) => a + c.snippet.length, 0)
if (snippets.length >= 1 && maxInputLength - snippetsLength < 600) {
break
}
const metaList: string[] = []
if (doc.kind == "file") {
metaList.push(`file: ${doc.name}\n`)
}
if (doc.contents.length > 3) {
metaList.push(`page: ${i + 1}\n`)
}
const meta = metaList.join("")
const maxDataLength =
maxInputLength - prompt.length - snippetsLength - meta.length - 100
let data = content.data.slice(content.sentLength)
data = semanticClip(data, maxDataLength)
const snippet = `\`\`\`\`md\n${meta}\n${data}\n\`\`\`\``
snippets.push({
key: doc.key,
index: i,
start: content.sentLength,
end: content.sentLength + data.length,
snippet: snippet,
})
}
}
const pendingText = snippets.map((p) => p.snippet).join("\n\n")
const message = pendingText ? `${prompt}\n\n${pendingText}` : ""
const done = docs.value.length > 0 && snippets.length == 0
return {
done,
message,
snippets,
}
})
watch(
() => chatDocsPanel.inputs,
async (inputs, old, onCleanup) => {
console.log("watch inputs: ", inputs)
if (inputs) {
for (let item of inputs) {
if (chatDocsPanel.docMap[item.key]) {
continue
}
const filename =
typeof item.data == "string" ? item.data.slice(0, 10) : item.data.name
chatDocsPanel.docMap[item.key] = {
key: item.key,
kind: item.kind,
name: filename,
loading: true,
success: false,
removed: false,
contents: [],
}
const doc = chatDocsPanel.docMap[item.key]
if (item.kind == "file" && typeof item.data != "string") {
const url = await convertBlobToBase64(item.data)
const results = await contentService.parseDoc({
key: item.key,
type: item.type,
size: item.data.size,
filename,
url,
})
const contents = results.map((v: string) => ({
data: v,
selected: true,
sentLength: 0,
}))
doc.loading = false
doc.contents = contents
doc.success = true
console.log("result: ", results, chatDocsPanel)
} else {
doc.loading = false
}
}
}
},
{ immediate: true }
)
const progress = computed(() => {
let totalCharacters = 0
let sentCharacters = 0
for (let doc of docs.value) {
for (let content of doc.contents) {
if (!content.selected) {
continue
}
totalCharacters += content.data.length
sentCharacters += content.sentLength
}
}
const currentSnippetsLength = currentMessage.value.snippets.reduce(
(a, c) => a + c.end - c.start,
0
)
const pending = sentCharacters + currentSnippetsLength
const pendingPrecent = Math.round((pending / totalCharacters) * 100) || 0
const sentPrecent = Math.round((sentCharacters / totalCharacters) * 100) || 0
console.log(pending, sentCharacters, totalCharacters)
return {
total: totalCharacters,
sent: sentCharacters,
pending,
pendingPrecent,
sentPrecent,
}
})
onMounted(() => {
const doc = div.value?.ownerDocument || document
const { host, pathname } = doc.location
const matchConfig = sitesConfig.find(
(c) => c.host == host && c.path.test(pathname)
)
if (matchConfig) {
config.maxInputLength = matchConfig.inputLength
config.selector = matchConfig.selector
}
console.log("site config: ", matchConfig)
})
const openDocSelect = (key: string) => {
sheet.value = "docSelect"
currentDoc.value = key
}
const removeItem = (key: string) => {
if (chatDocsPanel.docMap[key]) {
chatDocsPanel.docMap[key].removed = true
}
const i = chatDocsPanel.inputs.findIndex((v) => v.key == key)
chatDocsPanel.inputs.splice(i, 1)
}
const handleCopyMessage = () => {
const message = currentMessage.value.message
navigator.clipboard.writeText(message)
}
const nextMessage = () => {
for (let item of currentMessage.value.snippets) {
const content = chatDocsPanel.docMap[item.key]?.contents[item.index]
if (content.sentLength < item.end) {
content.sentLength = item.end
}
}
}
const autoSend = async () => {
if (currentMessage.value.snippets.length == 0) {
return
}
if (!config.selector) {
return
}
let key = "" + Math.random()
sendTask.key = key
sendTask.status = "running"
const isWorking = () =>
!currentMessage.value.done &&
sendTask.status == "running" &&
sendTask.key == key
while (isWorking()) {
const message = currentMessage.value.message
console.log(">>", message)
await new Promise((r) => setTimeout(r, 100))
const input = query(config.selector.input) as HTMLInputElement
if (!input) {
throw Error("couldn't find input element for " + config.selector.input)
}
await dispatchInput(input, message)
await new Promise((r) => setTimeout(r, 200))
await click(config.selector.send)
await new Promise((r) => setTimeout(r, 600))
await waitFor(config.selector.wait, 1000 * 30)
await new Promise((r) => setTimeout(r, 200))
nextMessage()
if (currentMessage.value.done) {
sendTask.status = "done"
}
}
}
const togglePause = () => {
sendTask.status = ""
}
const resetSent = () => {
for (let doc of Object.values(chatDocsPanel.docMap)) {
for (let content of doc.contents) {
content.sentLength = 0
}
}
}
</script>
<template>
<div ref="div" class="docs-panel">
<!-- Brand Header -->
<div class="flex items-center">
<img :src="logoUrl" class="w-6 h-6" />
<span class="mx-2 text-xl font-bold">PDF & Doc</span>
<button
aria-label="close"
class="ml-auto p-1 top-0 right-0 rounded-full hover:bg-rose-400/10"
@click="$emit('close')"
>
<IconClose class="w-5 h-5" />
</button>
</div>
<div class="relative">
<!-- primary panel -->
<ScrollView fade class="max-h-[560px]">
<div class="">
<div class="mb-4">
<div class="mb-2 text-base font-bold">Files</div>
<div class="flex flex-col gap-2">
<template v-for="(item, key) in chatDocsPanel.docMap">
<DocItem
v-if="!item.removed"
:item="item"
@remove="removeItem(key)"
@pick="openDocSelect(key)"
/>
</template>
<DocInput
v-if="chatDocsPanel.inputs.length == 0"
@input="(value) => (chatDocsPanel.inputs = value)"
/>
</div>
</div>
<div class="mb-4">
<div class="text-base font-bold">注入设置</div>
<div
class="text-sm my-2 px-3 py-1 rounded-lg bg-[var(--color-background-soft)]"
>
<div class="flex items-center justify-between my-1">
<span>Prompt</span>
<button
class="py-1 flex items-center"
@click="sheet = 'promptTemplate'"
>
<span class="px-2">编辑 Prompt</span>
<IconArrowRight />
</button>
</div>
<div class="flex items-center justify-between my-1">
<span>单次最大注入字符</span>
<input
v-model="config.maxInputLength"
class="border rounded px-2 py-1 w-20"
type="number"
min="1000"
step="1000"
/>
</div>
<div class="flex items-center justify-between my-1">
<span>最大注入次数</span>
<input
v-model="config.maxRuns"
class="border rounded px-2 py-1 w-20"
type="number"
min="1"
step="1"
/>
</div>
</div>
</div>
<div class="mb-4">
<div class="text-base font-bold">发送进度</div>
<div
class="text-sm px-3 my-2 py-1 rounded-lg bg-[var(--color-background-soft)]"
>
<div class="progress relative w-full h-2 my-2 rounded-full">
<div
class="absolute h-full rounded-full transition-all bg-primary/30"
:style="{
width: `${progress.pendingPrecent}%`,
}"
></div>
<div
class="absolute h-full rounded-full transition-all bg-primary"
:style="{
width: `${progress.sentPrecent}%`,
}"
></div>
</div>
<div class="my-2">
<div class="flex items-center">
<div v-if="!currentMessage.done">
发送消息 {{ currentMessage.message.length }} 字符
</div>
<div v-else>发送完成</div>
<span class="mx-auto"></span>
<div v-if="!currentMessage.done">
{{ progress.sent }} / {{ progress.total }}
</div>
<button
v-else
class="py-1 px-2 border rounded"
@click="resetSent"
>
重置
</button>
</div>
</div>
<div v-if="!currentMessage.done" class="my-2 flex items-center">
<div class="mr-auto">消息内容</div>
<button
class="py-1 px-2 border ml-2 rounded"
@click="handleCopyMessage"
>
复制
</button>
<button
class="py-1 px-2 border ml-2 rounded"
@click="nextMessage"
>
下一个
</button>
</div>
<div v-else class="my-2 flex items-center justify-between">
<div>你可以开始聊天了!</div>
<!-- <button>重置</button> -->
</div>
</div>
</div>
<div class="mb-0">
<div class="flex gap-2 justify-end">
<button
v-if="sendTask.status == 'running'"
class="flex items-center gap-1 px-3 py-1 bg-background-soft hover:bg-background-mute"
@click="togglePause"
>
<IconPause class="w-5 h-5" />
<span>暂停</span>
</button>
<button
:disabled="sendTask.status !== '' || docs.length == 0"
:class="[
'font-bold flex items-center gap-1 px-3 py-1 bg-primary-300 ',
'hover:bg-primary-400 dark:bg-primary-800 dark:hover:bg-primary-700',
'disabled:hover:bg-primary-300 disabled:dark:hover:bg-primary-800',
]"
@click="autoSend"
>
<IconProgressActivity
v-if="sendTask.status == 'running'"
class="w-5 h-5 animate-spin"
/>
<IconPlayCircle v-else class="w-5 h-5" />
自动发送
</button>
</div>
</div>
</div>
</ScrollView>
<!-- Sheet UI -->
<div
v-if="sheet != ''"
class="absolute w-full h-full top-0 left-0 flex flex-col bg-background"
>
<div class="flex items-center pt-3">
<button
@click="sheet = ''"
class="flex items-center"
aria-label="返回"
>
<IconArrowBack />
<span
v-if="sheet === 'docSelect'"
class="mx-2 text-base font-bold"
>{{ chatDocsPanel.docMap[currentDoc]?.name }}</span
>
<span
v-else-if="sheet === 'promptTemplate'"
class="mx-2 text-base font-bold"
>
Prompt模板
</span>
</button>
</div>
<ScrollView fade v-if="sheet == 'docSelect'">
<p class="text-sm pb-2">选择与你想了解的主题更相关的内容</p>
<input
class="w-full px-2 py-1 border"
placeholder="搜索"
type="text"
/>
<div class="grid grid-cols-3 gap-3 py-3">
<div
v-for="(content, i) of chatDocsPanel.docMap[currentDoc]?.contents"
>
<div
:title="content.data"
:class="[
'w-full h-0 pb-[130%] border-2 cursor-pointer bg-background-soft',
{
'border-primary': content.selected,
},
]"
@click="content.selected = !content.selected"
></div>
<div class="text-sm text-center">{{ i + 1 }}</div>
</div>
</div>
</ScrollView>
<ScrollView fade v-else-if="sheet == 'promptTemplate'">
<textarea
:class="[
'scrollbar border border-foreground/20 w-full h-36 p-2 bg-background-soft',
'outline-none',
]"
v-model="config.prompt"
></textarea>
<div class="flex gap-2 justify-end my-2">
<button class="px-2">取消</button>
<button class="px-2">保存</button>
</div>
</ScrollView>
</div>
</div>
</div>
</template>
<style scoped>
* {
/* border-color: var(--color-border); */
@apply border-foreground/20;
}
*:hover {
@apply border-foreground/30;
}
.sheet {
background-color: var(--color-background);
}
.progress {
background-color: rgba(var(--fg-rgb), 0.1);
}
.progress .sent {
}
.progress .pending {
}
</style>
<script setup lang="ts">
import IconNoteStackAdd from "@/components/icons/IconNoteStackAdd.vue"
import type { chatDocsPanel } from "@/store"
import { ref } from "vue"
const dragEnter = ref(false)
const emit = defineEmits({
input: (inputs: typeof chatDocsPanel.inputs) => {},
})
const handleFileInput = (e: Event) => {
const input = e.target as HTMLInputElement
if (input.files) {
const inputs = Array.from(input.files).map((file) => ({
key: crypto.randomUUID(),
kind: "file" as const,
type: file.type,
data: file,
}))
emit("input", inputs)
}
}
</script>
<template>
<label
for="anything-copilot-doc-input"
:class="[
'relative flex items-center justify-center gap-2 w-full h-14 ',
'rounded-lg border-2 bg-background-soft ',
dragEnter ? 'border-primary' : 'border-background-soft',
]"
>
<IconNoteStackAdd />
<span>拖拽或点击选择文件</span>
<input
multiple
type="file"
id="anything-copilot-doc-input"
class="opacity-0 w-full h-full absolute top-0 left-0 cursor-pointer"
@input="handleFileInput"
@dragenter="dragEnter = true"
@dragleave="dragEnter = false"
@dragover="(e) => e.preventDefault()"
/>
</label>
</template>
<style scoped></style>
<script setup lang="ts">
import IconClose from "@/components/icons/IconClose.vue"
import IconProgressActivity from "@/components/icons/IconProgressActivity.vue"
import IconNoteStack from "@/components/icons/IconNoteStack.vue"
import type { chatDocsPanel } from "@/store"
defineProps<{
item: (typeof chatDocsPanel.docMap)[0]
}>()
defineEmits(["remove", "pick"])
</script>
<template>
<div
v-if="!item.removed"
class="relative p-1 w-full flex gap-2 items-center rounded bg-background-soft"
>
<div
class="px-1 w-12 h-12 flex flex-col items-center justify-center shrink-0"
>
<IconProgressActivity v-if="item?.loading" class="w-8 h-8 animate-spin" />
<IconNoteStack v-else class="w-8 h-8" />
</div>
<div class="w-full min-w-0">
<div class="flex items-center mb-1">
<div class="mr-auto text-base truncate">
{{ item.name }}
</div>
<button
class="p-1 ml-1.5 rounded-full opacity-70 hover:opacity-100 hover:bg-rose-400/10"
aria-label="remove file"
@click="$emit('remove')"
>
<IconClose class="w-3.5 h-3.5" />
</button>
</div>
<div class="text-sm">
<span
>已选择{{ item.contents.filter((v) => v.selected).length }}/{{
item.contents.length
}}</span
>
<button class="text-primary px-2" @click="$emit('pick')">
选择范围
</button>
</div>
</div>
</div>
</template>
<style scoped></style>
export const sitesConfig = [
{
host: "huggingface.co",
path: /^\/chat/,
inputLength: 8000, // 2048 token
selector: {
input: "form[tabindex] textarea",
send: 'form[tabindex] button[type="submit"]',
wait: 'form[tabindex] button[type="submit"]:not([disabled=true])',
},
},
{
host: "chat.openai.com",
path: /^\//,
inputLength: 18000,
selector: {
input: "form textarea#prompt-textarea",
send: "form textarea ~ button",
wait: "form textarea ~ button",
},
},
{
host: "bard.google.com",
path: /^\/chat/,
inputLength: 4096,
selector: {
input: "input-area rich-textarea p",
send: "input-area div[class*=send] button[class*=send]",
wait: "input-area div[class*=send] button[class*=send]:not([hidden])",
},
},
{
host: "copilot.microsoft.com",
path: /^\//,
inputLength: 2048,
selector: {
input:
"cib-serp /deep/ cib-action-bar /deep/ cib-text-input /deep/ textarea",
send: "cib-serp /deep/ cib-action-bar /deep/ .bottom-right-controls button",
wait: "cib-serp /deep/ cib-action-bar /deep/ cib-typing-indicator /deep/ button[disabled]",
},
},
{
host: "yiyan.baidu.com",
path: /^\//,
inputLength: 2000,
selector: {
input: "textarea:not(h1 ~ textarea)",
send: 'div > span:has(svg[width="240"])',
wait: 'div > span:has(svg[width="240"]):not([style*="display: none"])',
},
},
]
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 -960 960 960"
width="24"
fill="currentColor"
>
<path
d="m313-440 224 224-57 56-320-320 320-320 57 56-224 224h487v80H313Z"
/>
</svg>
</template>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 -960 960 960"
width="24"
fill="currentColor"
>
<path d="M504-480 320-664l56-56 240 240-240 240-56-56 184-184Z" />
</svg>
</template>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 -960 960 960"
width="24"
fill="currentColor"
>
<path
d="M280-160v-441q0-33 24-56t57-23h439q33 0 56.5 23.5T880-600v320L680-80H360q-33 0-56.5-23.5T280-160ZM81-710q-6-33 13-59.5t52-32.5l434-77q33-6 59.5 13t32.5 52l10 54h-82l-7-40-433 77 40 226v279q-16-9-27.5-24T158-276L81-710Zm279 110v440h280v-160h160v-280H360Zm220 220Z"
/>
</svg>
</template>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 -960 960 960"
width="24"
fill="currentColor"
>
<path
d="M280-160v-441q0-33 24-56t57-23h439q33 0 56.5 23.5T880-600v320L680-80H360q-33 0-56.5-23.5T280-160ZM81-710q-6-33 13-59.5t52-32.5l434-77q33-6 59.5 13t32.5 52l10 54h-82l-7-40-433 77 40 226v279q-16-9-27.5-24T158-276L81-710Zm279 110v440h280l160-160v-280H360Zm220 220Zm-40 160h80v-120h120v-80H620v-120h-80v120H420v80h120v120Z"
/>
</svg>
</template>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 -960 960 960"
width="24"
fill="currentColor"
>
<path
d="M520-200v-560h240v560H520Zm-320 0v-560h240v560H200Zm400-80h80v-400h-80v400Zm-320 0h80v-400h-80v400Zm0-400v400-400Zm320 0v400-400Z"
/>
</svg>
</template>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 -960 960 960"
width="24"
fill="currentColor"
>
<path
d="m380-300 280-180-280-180v360ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"
/>
</svg>
</template>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 -960 960 960"
width="24"
fill="currentColor"
>
<path
d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-155.5t86-127Q252-817 325-848.5T480-880q17 0 28.5 11.5T520-840q0 17-11.5 28.5T480-800q-133 0-226.5 93.5T160-480q0 133 93.5 226.5T480-160q133 0 226.5-93.5T800-480q0-17 11.5-28.5T840-520q17 0 28.5 11.5T880-480q0 82-31.5 155t-86 127.5q-54.5 54.5-127 86T480-80Z"
/>
</svg>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { pipLauncher } from "@/store"; import { pipLauncher } from "@/store"
import PipLauncher from "@/components/PipLauncher.vue"; import PipLauncher from "@/components/PipLauncher.vue"
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n"
import ChatDocsAddon from "@/components/chatdocs/ChatDocsAddon.vue"
const { t } = useI18n(); const { t } = useI18n()
</script> </script>
<template> <template>
...@@ -12,6 +13,8 @@ const { t } = useI18n(); ...@@ -12,6 +13,8 @@ const { t } = useI18n();
v-if="pipLauncher.visible" v-if="pipLauncher.visible"
@close="pipLauncher.visible = false" @close="pipLauncher.visible = false"
/> />
<ChatDocsAddon />
</template> </template>
<style scoped></style> <style scoped></style>
...@@ -3,6 +3,7 @@ import Multitasking from "@/components/Multitasking.vue" ...@@ -3,6 +3,7 @@ import Multitasking from "@/components/Multitasking.vue"
import PipSplash from "@/components/PipSplash.vue" import PipSplash from "@/components/PipSplash.vue"
import { pipLoading } from "@/store" import { pipLoading } from "@/store"
import LoadingBar from "@/components/LoadingBar.vue" import LoadingBar from "@/components/LoadingBar.vue"
import ChatDocsAddon from "@/components/chatdocs/ChatDocsAddon.vue"
</script> </script>
<template> <template>
...@@ -21,6 +22,8 @@ import LoadingBar from "@/components/LoadingBar.vue" ...@@ -21,6 +22,8 @@ import LoadingBar from "@/components/LoadingBar.vue"
> >
<LoadingBar /> <LoadingBar />
</div> </div>
<ChatDocsAddon />
</template> </template>
<style scoped></style> <style scoped></style>
...@@ -8,6 +8,7 @@ import { ...@@ -8,6 +8,7 @@ import {
addContentEventListener, addContentEventListener,
removeContentEventListener, removeContentEventListener,
} from "@/content/event" } from "@/content/event"
import { contentService } from "@/utils/service"
// import { PipEventName } from "@/types/pip" // import { PipEventName } from "@/types/pip"
function handleMessage( function handleMessage(
...@@ -41,6 +42,9 @@ function handleMessage( ...@@ -41,6 +42,9 @@ function handleMessage(
pipWindowId: message.window.id, pipWindowId: message.window.id,
}) })
break break
case MessageType.invokeResponse:
contentService.handleMessage(message)
break
} }
} }
......
...@@ -14,7 +14,7 @@ export function mount(App: Component, doc = document) { ...@@ -14,7 +14,7 @@ export function mount(App: Component, doc = document) {
const link = doc.createElement("link") const link = doc.createElement("link")
link.rel = "stylesheet" link.rel = "stylesheet"
link.href = chrome.runtime?.getURL("/index.css") link.href = chrome.runtime?.getURL("/assets/index.css")
root.append(link) root.append(link)
root.append(appContainer) root.append(appContainer)
doc.documentElement.append(outter) doc.documentElement.append(outter)
......
const contentCss = "index.css"; const __DEV__ = process.env.NODE_ENV == "development"
const contentCss = "/assets/index.css"
const manifest = { const manifest = {
manifest_version: 3, manifest_version: 3,
...@@ -16,7 +18,7 @@ const manifest = { ...@@ -16,7 +18,7 @@ const manifest = {
32: "logo.png", 32: "logo.png",
}, },
default_title: "__MSG_short_name__", default_title: "__MSG_short_name__",
default_popup: "popup.html", default_popup: "src/pages/popup.html",
}, },
default_locale: "en", default_locale: "en",
icons: { icons: {
...@@ -33,18 +35,18 @@ const manifest = { ...@@ -33,18 +35,18 @@ const manifest = {
content_scripts: [ content_scripts: [
{ {
matches: ["<all_urls>"], matches: ["<all_urls>"],
js: ["main.js"], js: ["/js/content-main.js"],
run_at: "document_start", run_at: "document_start",
world: "MAIN", world: "MAIN",
}, },
{ {
matches: ["<all_urls>"], matches: ["<all_urls>"],
js: ["content.js"], js: ["/js/content.js"],
run_at: "document_start", run_at: "document_start",
}, },
], ],
options_page: "guide.html", options_page: __DEV__ ? "/src/pages/guide.html" : "",
permissions: ["tabs", "scripting", "activeTab", "storage"], permissions: ["tabs", "scripting", "activeTab", "storage", "offscreen"],
host_permissions: ["<all_urls>"], host_permissions: ["<all_urls>"],
minimum_chrome_version: "111", minimum_chrome_version: "111",
commands: { commands: {
...@@ -58,16 +60,15 @@ const manifest = { ...@@ -58,16 +60,15 @@ const manifest = {
}, },
web_accessible_resources: [ web_accessible_resources: [
{ {
resources: [contentCss], resources: [contentCss, "logo.svg"],
matches: ["<all_urls>"], matches: ["<all_urls>"],
}, },
], ],
content_security_policy: content_security_policy: __DEV__
process.env.NODE_ENV == "development"
? { ? {
extension_pages: `script-src 'self' http://localhost:3000;`, extension_pages: `script-src 'self' http://localhost:3000;`,
} }
: undefined, : undefined,
}; }
export default manifest; export default manifest
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, reactive, watch } from "vue" import { ref, onMounted, onUnmounted, computed, reactive, watch } from "vue"
import { emptyTab, checkContent } from "@/utils/ext" import { emptyTab, checkContent, getStoreUrl } from "@/utils/ext"
import { items, pipWindow } from "@/store" import { items, pipWindow } from "@/store"
import PipWindowActions from "@/components/popup/PipWindowActions.vue" import PipWindowActions from "@/components/popup/PipWindowActions.vue"
import IconThumbUp from "@/components/icons/IconThumbUp.vue" import IconThumbUp from "@/components/icons/IconThumbUp.vue"
...@@ -9,7 +9,6 @@ import IconGithub from "@/components/icons/IconGithub.vue" ...@@ -9,7 +9,6 @@ import IconGithub from "@/components/icons/IconGithub.vue"
import IconDiscord from "@/components/icons/IconDiscord.vue" import IconDiscord from "@/components/icons/IconDiscord.vue"
import IconXLogo from "@/components/icons/IconXLogo.vue" import IconXLogo from "@/components/icons/IconXLogo.vue"
import { useI18n } from "@/utils/i18n" import { useI18n } from "@/utils/i18n"
import { getStoreUrl } from "@/utils/store"
const { t } = useI18n() const { t } = useI18n()
......
...@@ -8,6 +8,6 @@ ...@@ -8,6 +8,6 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/pages/guide.ts"></script> <script type="module" src="./guide.ts"></script>
</body> </body>
</html> </html>
import "@/assets/main.css"; import "@/assets/main.css"
import { createApp } from "vue"; import { createApp } from "vue"
import Guide from "./Popup.vue"; import Guide from "./Popup.vue"
import { i18n } from "@/utils/i18n"
createApp(Guide).mount("#app"); const app = createApp(Guide)
function injectContent() { app.use(i18n)
const mainScript = document.createElement("script"); app.mount("#app")
mainScript.src = "/main.js";
const script = document.createElement("script");
script.src = "/content.js";
document.head.append(mainScript);
document.head.append(script);
}
injectContent();
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Anything Copilot</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./offscreen.ts"></script>
</body>
</html>
import * as pdfjs from "pdfjs-dist"
import { parseContent } from "@/utils/pdf"
import { convertToHtml, images } from "mammoth"
import TurndownService from "turndown"
import { MessageType, ServiceFunc } from "@/types"
// import { fileTypeFromBlob } from "file-type"
import type { ParseDocOptions } from "@/types"
pdfjs.GlobalWorkerOptions.workerSrc = "/js/pdf.worker.js"
const service = new TurndownService({ headingStyle: "atx" })
async function parsePdf(file: File) {
const buffer = await file.arrayBuffer()
const task = pdfjs.getDocument(buffer)
const pdf = await task.promise
const contents = []
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i)
const root = await parseContent(page)
const markdown = service.turndown(root.innerHTML)
contents.push(markdown)
}
return contents
}
async function parseDocx(file: File) {
const buffer = await file.arrayBuffer()
const result = await convertToHtml(
{ arrayBuffer: buffer },
{
convertImage: images.imgElement(async (img) => ({ src: "" })),
}
)
console.log("result: ", result)
const markdown = service.turndown(result.value)
return [markdown]
}
async function parseDoc({ type, size, url, filename }: ParseDocOptions) {
const res = await fetch(url)
const blob = await res.blob()
const file = new File([blob], filename, { type })
let contents: string[] = []
switch (type) {
case "application/pdf":
contents = await parsePdf(file)
break
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
contents = await parseDocx(file)
break
case "application/msword":
break
}
return contents
}
async function handleMessage(
message: any,
sender: chrome.runtime.MessageSender
) {
if (message?.type === MessageType.toOffscreen) {
console.log("offscreen message: ", message, sender)
let taskPromise: Promise<any> | null = null
switch (message?.task) {
case ServiceFunc.parseDoc:
taskPromise = parseDoc(message.payload)
break
}
if (taskPromise) {
const result = await taskPromise
chrome.runtime.sendMessage({
type: MessageType.fromOffscreen,
key: message.key,
task: message.task,
payload: result,
})
}
}
}
chrome.runtime.onMessage.addListener(handleMessage)
// for testing purposes
document.onclick = () => {
console.log("CLICK")
const input = document.createElement("input")
input.type = "file"
input.click()
input.oninput = (e) => {
const files = input.files
if (!files || files.length === 0) return
console.log(e.type, files)
const file = files[0]
const url = URL.createObjectURL(file)
const key = "" + Math.random()
console.log("url: ", url)
parseDoc({
key,
url,
type: file.type,
size: file.size,
filename: file.name,
})
}
}
...@@ -8,6 +8,6 @@ ...@@ -8,6 +8,6 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/pages/popup.ts"></script> <script type="module" src="./popup.ts"></script>
</body> </body>
</html> </html>
import "@/assets/main.css"; import "@/assets/main.css"
import { createApp } from "vue"; import { createApp } from "vue"
import Popup from "./Popup.vue"; import Popup from "./Popup.vue"
import { i18n } from "@/utils/i18n"; import { i18n } from "@/utils/i18n"
const app = createApp(Popup); const app = createApp(Popup)
app.use(i18n);
app.mount("#app");
app.use(i18n)
app.mount("#app")
import { reactive, ref } from "vue"; import { reactive, ref } from "vue"
export const contentCss = ref("");
export const pipLauncher = reactive({
visible: false,
});
/** popup state */
export const items = reactive([ export const items = reactive([
{ {
url: "https://chat.openai.com/", url: "https://chat.openai.com/",
img: "/chatgpt.svg", img: "/img/chatgpt.svg",
title: "ChatGPT - OpenAI", title: "ChatGPT - OpenAI",
}, },
{ {
url: "https://bard.google.com/", url: "https://bard.google.com/",
img: "/bard.svg", img: "/img/bard.svg",
title: "Bard - Google AI", title: "Bard - Google AI",
}, },
{ {
url: "https://claude.ai/", url: "https://claude.ai/",
img: "/claude-ai.svg", img: "/img/claude-ai.svg",
title: "Claude", title: "Claude",
}, },
{ {
url: "https://tiktok.com/", url: "https://tiktok.com/",
img: "/tiktok.svg", img: "/img/tiktok.svg",
title: "Tiktok", title: "Tiktok",
}, },
]); ])
export const contentCss = ref("")
export const pipLauncher = reactive({
visible: false,
})
export const pipWindow = reactive({ export const pipWindow = reactive({
id: 0, id: 0,
window: null as Window | null, window: null as Window | null,
windowsWindow: null as chrome.windows.Window | null, windowsWindow: null as chrome.windows.Window | null,
tab: null as chrome.tabs.Tab | null, tab: null as chrome.tabs.Tab | null,
}); })
export const pipLoading = reactive({ export const pipLoading = reactive({
isLoading: true, isLoading: true,
splashScreen: true, splashScreen: true,
}); })
export const docsAddon = reactive({
visible: false,
active: false,
})
type DocInputItem = {
key: string
kind: "file" | "string"
type: string
data: File | string
}
type Content = {
data: string
selected: boolean
sentLength: number
}
type DocMap = Record<
string,
{
key: string
kind: "string" | "file"
name: string
loading: boolean
success: boolean
removed: boolean
contents: Content[]
}
>
export const chatDocsPanel = reactive({
visible: false,
inputs: [] as DocInputItem[],
docMap: {} as DocMap,
})
...@@ -10,4 +10,31 @@ export enum MessageType { ...@@ -10,4 +10,31 @@ export enum MessageType {
pipWinInfo = "pip-win-info", pipWinInfo = "pip-win-info",
updateWindow = "update-window", updateWindow = "update-window",
removeWindow = "remove-window", removeWindow = "remove-window",
setupOffscreenDocument = "setup-offscreen-document",
toOffscreen = "to-offscreen",
fromOffscreen = "from-offscreen",
invokeRequest = "invoke-request",
invokeResponse = "invoke-Response",
}
export enum ServiceFunc {
parseDoc = "parse-doc",
}
export interface InvokeRequest {
func: string
args: any[]
}
export interface InvokeMessage extends InvokeRequest {
type: MessageType
key: string
}
export type ParseDocOptions = {
key: string
filename: string
type: string
size: number
url: string
} }
type CallbackItem = {
resolve: (v: any) => void
reject: (e: any) => void
}
abstract class Invoke {
private pendingCallback: Record<string, CallbackItem>
private count: number
private name: string
constructor(name: string) {
this.name = name
this.pendingCallback = {}
this.count = 0
}
protected get key() {
return `${this.name}-${this.count++}`
}
protected getReturnValue(key: string) {
const promise = new Promise((resolve, reject) => {
this.pendingCallback[key] = { resolve, reject }
})
return promise
}
protected setReturnValue(key: string, success: boolean, payload: any) {
const callback = this.pendingCallback[key]
if (callback) {
const fn = success != false ? callback.resolve : callback.reject
fn(payload)
delete this.pendingCallback[key]
}
}
abstract send(req: any): Promise<{ key: string; response: any }>
abstract handleMessage(message: any): void
public async invoke(req: any) {
const { key } = await this.send(req)
const result = await this.getReturnValue(key)
delete this.pendingCallback[key]
return result
}
}
export default Invoke
export { Invoke }
type VNodeName =
| "html"
| "body"
| "main"
| "div"
| "section"
| "p"
| "span"
| "b"
| "a"
| "#text"
| "br"
| "figure"
| "figcaption"
const htmlEscape = (str: string) =>
str.replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
const htmlUnescape = (str: string) =>
str
.replace(/&quot;/g, '"')
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
class VNode {
public name: VNodeName
public childs: VNode[]
public key: string
public text?: string
public id?: string
public href?: string
public alt?: string
#parent: VNode | null
constructor(name: string) {
this.name = name as VNodeName
this.childs = []
this.#parent = null
this.key = ""
}
get innerHTML(): string {
return this.childs.map((c) => c.html).join("")
}
get html(): string {
if (this.name == "#text") {
return htmlEscape(this.text || "")
}
const name = this.name
const innerHTML = this.innerHTML
let attr = Object.entries(this.attributes)
.map(([k, v]) => `${k}="${htmlEscape(v)}"`)
.join(" ")
attr = attr ? ` ${attr} ` : ""
const template = `<${name}${attr}>${innerHTML}</${name}>`
return template
}
get parent() {
return this.#parent
}
get attributes() {
const attr: Record<string, string> = {}
this.href && (attr.href = this.href)
this.alt && (attr.alt = this.alt)
return attr
}
public append(n: VNode) {
if (this.name == "#text") {
throw Error("Cannot append children within a text node")
}
n.#parent = this
this.childs.push(n)
}
public $(selector: string): VNode | null {
if (selector.startsWith("#") && this.id == selector.slice(1)) {
return this
}
if (this.name == selector) {
return this
}
for (let child of this.childs) {
if (child instanceof VNode) {
const n = child.$(selector)
if (n) {
return n
}
}
}
return null
}
}
export default VNode
export { VNode }
type Selector = string | { xpath: string }; type Selector = string | { xpath: string }
/** Custom query function that supports shadow DOM penetration with /deep/ combinator and XPath /** Custom query function that supports shadow DOM penetration with /deep/ combinator and XPath
* */ * */
...@@ -6,9 +6,9 @@ export function query(selector: Selector) { ...@@ -6,9 +6,9 @@ export function query(selector: Selector) {
if (typeof selector === "string") { if (typeof selector === "string") {
// Check for /deep/ combinator in the selector // Check for /deep/ combinator in the selector
if (selector.includes("/deep/")) { if (selector.includes("/deep/")) {
return queryShadowDom(document.documentElement, selector.split("/deep/")); return queryShadowDom(document.documentElement, selector.split("/deep/"))
} else { } else {
return document.querySelector(selector); return document.querySelector(selector)
} }
} else if (selector.xpath) { } else if (selector.xpath) {
// Handle XPath selector // Handle XPath selector
...@@ -18,88 +18,110 @@ export function query(selector: Selector) { ...@@ -18,88 +18,110 @@ export function query(selector: Selector) {
null, null,
XPathResult.FIRST_ORDERED_NODE_TYPE, XPathResult.FIRST_ORDERED_NODE_TYPE,
null null
); )
return result.singleNodeValue as Element; return result.singleNodeValue as Element
} }
return null; return null
} }
function queryShadowDom(el: Element, parts: string[]) { function queryShadowDom(
return null; el: Element | ShadowRoot,
parts: string[]
): Element | null {
if (parts.length == 1) {
return el.querySelector(parts[0])
}
const selector = parts[0]
if (!selector) {
return null
}
for (let node of el.querySelectorAll(selector)) {
if (!node.shadowRoot) {
continue
}
const value = queryShadowDom(node.shadowRoot, parts.slice(1))
if (!value) {
continue
}
return value
}
return null
} }
export function querySome(selectors: Selector[]) { export function querySome(selectors: Selector[]) {
for (let selector of selectors) { for (let selector of selectors) {
const result = query(selector); const result = query(selector)
if (result) { if (result) {
return result; return result
} }
} }
return null; return null
} }
export function copyStyleSheets(pipWindow: Window, document: Document) { export function copyStyleSheets(pipWindow: Window, document: Document) {
[...document.styleSheets].forEach((styleSheet) => { ;[...document.styleSheets].forEach((styleSheet) => {
try { try {
const cssRules = [...styleSheet.cssRules] const cssRules = [...styleSheet.cssRules]
.map((rule) => rule.cssText) .map((rule) => rule.cssText)
.join(""); .join("")
const style = document.createElement("style"); const style = document.createElement("style")
style.textContent = cssRules; style.textContent = cssRules
pipWindow.document.head.appendChild(style); pipWindow.document.head.appendChild(style)
} catch (e) { } catch (e) {
const link = document.createElement("link"); const link = document.createElement("link")
link.rel = "stylesheet"; link.rel = "stylesheet"
link.type = styleSheet.type; link.type = styleSheet.type
link.media = styleSheet.media as any; link.media = styleSheet.media as any
link.href = styleSheet.href as string; link.href = styleSheet.href as string
pipWindow.document.head.appendChild(link); pipWindow.document.head.appendChild(link)
} }
}); })
} }
export function getDomNonce(doc: Document) { export function getDomNonce(doc: Document) {
const nonce = { style: "", script: "" }; const nonce = { style: "", script: "" }
const elements = doc.querySelectorAll< const elements = doc.querySelectorAll<
HTMLScriptElement | HTMLStyleElement | HTMLLinkElement HTMLScriptElement | HTMLStyleElement | HTMLLinkElement
>("[nonce"); >("[nonce")
const isLink = (el: HTMLElement): el is HTMLLinkElement => const isLink = (el: HTMLElement): el is HTMLLinkElement =>
el.nodeName == "LINK"; el.nodeName == "LINK"
for (let element of elements) { for (let element of elements) {
const code = element.nonce; const code = element.nonce
if (!code) continue; if (!code) continue
if (element.nodeName == "SCRIPT" && !nonce.script) { if (element.nodeName == "SCRIPT" && !nonce.script) {
nonce.script = code; nonce.script = code
continue; continue
} }
if (isLink(element) && element.as == "script" && !nonce.script) { if (isLink(element) && element.as == "script" && !nonce.script) {
nonce.script = code; nonce.script = code
continue; continue
} }
if (element.nodeName == "STYLE" && !nonce.style) { if (element.nodeName == "STYLE" && !nonce.style) {
nonce.style = code; nonce.style = code
continue; continue
} }
if ( if (
isLink(element) && isLink(element) &&
element.rel?.search("stylesheet") > -1 && element.rel?.search("stylesheet") > -1 &&
!nonce.style !nonce.style
) { ) {
nonce.style = code; nonce.style = code
continue; continue
} }
if (isLink(element) && element.as == "style" && !nonce.style) { if (isLink(element) && element.as == "style" && !nonce.style) {
nonce.style = code; nonce.style = code
continue; continue
} }
} }
return nonce; return nonce
} }
export function replaceHtmlNonce( export function replaceHtmlNonce(
...@@ -108,29 +130,65 @@ export function replaceHtmlNonce( ...@@ -108,29 +130,65 @@ export function replaceHtmlNonce(
) { ) {
const replacer = (match: string, p1: string, p2: string) => { const replacer = (match: string, p1: string, p2: string) => {
const isScript = const isScript =
p1 == "script" || (p1 == "link" && match.search("script") > -1); p1 == "script" || (p1 == "link" && match.search("script") > -1)
const isStyle = const isStyle =
p1 == "style" || (p1 == "link" && match.search("style") > -1); p1 == "style" || (p1 == "link" && match.search("style") > -1)
let r = match; let r = match
if (isScript) { if (isScript) {
r = match.replace(p2, nonce.script); r = match.replace(p2, nonce.script)
} }
if (isStyle) { if (isStyle) {
r = match.replace(p2, nonce.style); r = match.replace(p2, nonce.style)
} }
return r; return r
}; }
let txt = html.replace(/<(script|style|link)\s[^>]*nonce="(.+?)"/g, replacer); let txt = html.replace(/<(script|style|link)\s[^>]*nonce="(.+?)"/g, replacer)
return txt; return txt
} }
export function removePrerenderRules(doc: Document) { export function removePrerenderRules(doc: Document) {
const rules = doc.querySelectorAll('script[type="speculationrules"]'); const rules = doc.querySelectorAll('script[type="speculationrules"]')
if (rules) { if (rules) {
rules.forEach((s) => s.remove()); rules.forEach((s) => s.remove())
}
}
export async function dispatchInput(
input: HTMLInputElement | HTMLTextAreaElement,
value: string
) {
if (["INPUT", "TEXTAREA"].includes(input.nodeName)) {
input.value = value
} else if (input.contentEditable) {
input.innerText = value
}
input.dispatchEvent(new Event("input", { bubbles: true }))
input.dispatchEvent(new Event("change", { bubbles: true }))
}
export async function click(target: string) {
const el = query(target) as HTMLElement
el.click()
}
export async function waitFor(target: string, timeout?: number) {
let success = false
let count = 0
const t0 = Date.now()
while (!success) {
const view = query(target)
success = !!view
count++
await new Promise((r) => setTimeout(r, 200))
if (timeout && Date.now() - t0 > timeout) {
throw Error(`click() timeout, target: ${target} wait: ${waitFor}`)
}
} }
} }
...@@ -48,7 +48,7 @@ export async function waitMessage<T = unknown>({ ...@@ -48,7 +48,7 @@ export async function waitMessage<T = unknown>({
}; };
chrome.runtime.onMessage.addListener(handleMessage); chrome.runtime.onMessage.addListener(handleMessage);
if (timeout) { if (timeout) {
timer = setTimeout(() => { timer = window.setTimeout(() => {
chrome.runtime.onMessage.removeListener(handleMessage); chrome.runtime.onMessage.removeListener(handleMessage);
reject(); reject();
}, timeout); }, timeout);
...@@ -130,3 +130,45 @@ export async function checkContent(tabId: number) { ...@@ -130,3 +130,45 @@ export async function checkContent(tabId: number) {
return false; return false;
} }
} }
type StoreUrlOptions = {
id: string
name: string
reviews?: boolean
}
export function getChromeWebStoreUrl({ id, name, reviews }: StoreUrlOptions) {
const u = new URL("https://chromewebstore.google.com/")
const slug = name.replace(/[\s/]+/g, "-") || "-"
u.pathname = `/detail/${slug}/${id}`
if (reviews) {
u.pathname = u.pathname + "/reviews"
}
return u.href
}
export function getEdgeAddonsUrl({ id, name, reviews }: StoreUrlOptions) {
const u = new URL("https://microsoftedge.microsoft.com")
const slug = name.replace(/[\s/]+/g, "-") || "-"
u.pathname = `/addons/detail/${slug}/${id}`
return u.href
}
export function getStoreUrl(options: StoreUrlOptions) {
const id = chrome.runtime.id
if (id.startsWith("lilckelmo")) {
return getChromeWebStoreUrl(options)
}
if (id.startsWith("lbeehbkc")) {
return getEdgeAddonsUrl(options)
}
return getEdgeAddonsUrl({
...options,
id: "lbeehbkcmjaopnlccpjcdgamcabhnanl",
})
}
import type { PDFPageProxy } from "pdfjs-dist"
import type { StructTreeContent } from "pdfjs-dist/types/src/display/api"
import VNode from "./VNode"
type TextContent = Awaited<ReturnType<PDFPageProxy["getTextContent"]>>
type StructTreeNode = Awaited<ReturnType<PDFPageProxy["getStructTree"]>>
type Annotation = {
id: string
subtype: string
url: string
}
export type FlatStructItem = {
id: string
type: string
roles: string[]
}
function parseStructTree(structTree?: StructTreeNode) {
const root = new VNode("div")
const roles: Record<string, string> = {
Document: "main",
NonStruct: "",
P: "p",
L: "ul",
H1: "h1",
H2: "h2",
H3: "h3",
H4: "h4",
H5: "h5",
Link: "a",
LI: "li",
Table: "table",
TR: "tr",
TH: "th",
TD: "td",
Figure: "figure",
}
const parse = (node: VNode, tree: StructTreeNode): VNode => {
let current = node
const { role, children } = tree
const name = roles[role]
if (name) {
const n = new VNode(name)
if (name === "figure") {
if ("alt" in tree) {
n.alt = tree.alt as string
}
// const img = new VNode('img')
// img.alt = tree.alt as string
}
current.append(n)
current = n
}
for (let child of children) {
if ("role" in child) {
parse(current, child)
continue
}
if (child.type == "content") {
const n = current.name == "figure" ? "figcaption" : "#text"
const textNode = new VNode(n)
textNode.id = child.id
current.append(textNode)
}
if (child.type == "object" && current.name == "a") {
current.id = child.id
}
}
return node
}
if (structTree) {
parse(root, structTree)
}
return root
}
export async function parseContent(page: PDFPageProxy) {
const structTree = await page.getStructTree()
const content = await page.getTextContent({
includeMarkedContent: true,
})
const annotations: Annotation[] = await page.getAnnotations()
console.log("structTree: ", structTree, content)
const root = parseStructTree(structTree)
for (let annotation of annotations) {
const { id, subtype, url } = annotation
const node = root.$(`#${id}`)
if (node && node.name == "a" && subtype == "Link" && url) {
node.href = url
}
}
let current = root.$("body") || root
let span: VNode | null = null
const items = [...content.items, { type: "end" as const }]
for (let item of items) {
if ("type" in item && span) {
// add span to current
const isPureText = span.childs.every((c) => c.name == "#text")
if (isPureText && current.name == "#text") {
current.text = span.innerHTML
} else {
if (current.name == "#text") {
current.name = "span"
}
current.append(span)
}
span = null
}
if ("str" in item) {
if (!span) {
span = new VNode("span")
}
const textNode = new VNode("#text")
textNode.text = item.str
span.append(textNode)
if (item.hasEOL) {
const eolNode = new VNode("#text")
eolNode.text = "\n"
span.append(eolNode)
// const brNode = new VNode("br")
// span.append(brNode)
}
continue
}
if ("type" in item && item.type == "beginMarkedContentProps") {
const id = item.id
const node = root.$(`#${id}`)
if (node) {
current = node
}
continue
}
if ("type" in item && item.type == "endMarkedContent") {
if (current.parent) {
current = current.parent
}
continue
}
}
console.log(root, root.html)
return root
}
import {
MessageType,
type InvokeRequest,
type ParseDocOptions,
ServiceFunc,
} from "@/types"
import Invoke from "./Invoke"
class Service extends Invoke {
public async send(req: any) {
const key = this.key
const response = await chrome.runtime.sendMessage({
type: MessageType.invokeRequest,
key,
...req,
})
return { key, response }
}
public handleMessage(message: any) {
if (message?.type === MessageType.invokeResponse) {
const { key, success, payload } = message
this.setReturnValue(key, success, payload)
}
}
public async invoke(req: InvokeRequest): Promise<any> {
return super.invoke(req)
}
public parseDoc(options: ParseDocOptions): Promise<string[]> {
return this.invoke({
func: ServiceFunc.parseDoc,
args: [options],
})
}
}
const contentService = new Service("content")
export { Service, contentService }
type Options = {
id: string
name: string
reviews?: boolean
}
export function getChromeWebStoreUrl({ id, name, reviews }: Options) {
const u = new URL("https://chromewebstore.google.com/")
const slug = name.replace(/[\s/]+/g, "-") || "-"
u.pathname = `/detail/${slug}/${id}`
if (reviews) {
u.pathname = u.pathname + "/reviews"
}
return u.href
}
export function getEdgeAddonsUrl({ id, name, reviews }: Options) {
const u = new URL("https://microsoftedge.microsoft.com")
const slug = name.replace(/[\s/]+/g, "-") || "-"
u.pathname = `/addons/detail/${slug}/${id}`
return u.href
}
export function getStoreUrl(options: Options) {
const id = chrome.runtime.id
if (id.startsWith("lilckelmo")) {
return getChromeWebStoreUrl(options)
}
if (id.startsWith("lbeehbkc")) {
return getEdgeAddonsUrl(options)
}
return getEdgeAddonsUrl({
...options,
id: "lbeehbkcmjaopnlccpjcdgamcabhnanl",
})
}
export const convertBlobToBase64 = (blob: Blob) => {
return new Promise<string>((resolve) => {
const reader = new FileReader()
reader.readAsDataURL(blob)
reader.onloadend = () => {
const base64data = reader.result as string
resolve(base64data)
}
})
}
export const semanticClip = (text: string, maxLength: number) => {
if (text.length <= maxLength) {
return text
}
let breakPoint = maxLength
let breakScore = 0
for (let i = maxLength; i > 0; i--) {
if (maxLength - i > 500 || (maxLength - i) / maxLength > 0.2) {
break
}
let scoreBase = 0
let char = text[i]
if (text[i] == "\n") {
scoreBase = 8
} else if ([".", "?", "!"].includes(char) && text[i + 1] == " ") {
scoreBase = 5
} else if (["。", "?", "!"].includes(char)) {
scoreBase = 5
} else if ([";", ";", ","].includes(char)) {
scoreBase = 2
} else if (char == " ") {
scoreBase = 0.5
}
if (scoreBase) {
let point = i + 1
let score = scoreBase * (1000 / (maxLength - i + 1000))
if (score > breakScore) {
breakPoint = point
breakScore = score
}
}
}
return text.slice(0, breakPoint)
}
...@@ -63,7 +63,28 @@ module.exports = { ...@@ -63,7 +63,28 @@ module.exports = {
9: "36px", 9: "36px",
10: "40px", 10: "40px",
}, },
colors: {
primary: {
DEFAULT: "hsl(var(--primary-hsl) / <alpha-value>)",
200: "hsl(var(--primary-hue) var(--primary-s) 80% / <alpha-value>)",
300: "hsl(var(--primary-hue) var(--primary-s) 70% / <alpha-value>)",
400: "hsl(var(--primary-hue) var(--primary-s) 60% / <alpha-value>)",
500: "hsl(var(--primary-hue) var(--primary-s) 50% / <alpha-value>)",
600: "hsl(var(--primary-hue) var(--primary-s) 40% / <alpha-value>)",
700: "hsl(var(--primary-hue) var(--primary-s) 30% / <alpha-value>)",
800: "hsl(var(--primary-hue) var(--primary-s) 20% / <alpha-value>)",
900: "hsl(var(--primary-hue) var(--primary-s) 15% / <alpha-value>)",
},
background: {
DEFAULT: "hsl(var(--bg-hsl) / <alpha-value>)",
soft: "hsl(var(--bg-hue) var(--bg-s) var(--bg-soft-l) / <alpha-value>)",
mute: "hsl(var(--bg-hue) var(--bg-s) var(--bg-mute-l) / <alpha-value>)",
},
foreground: {
DEFAULT: "hsl(var(--fg-hsl) / <alpha-value>)",
},
},
}, },
}, },
plugins: [], plugins: [],
}; }
import { describe, test, expect } from "vitest"
import { parseContent } from "@/utils/pdf"
import { readFile } from "node:fs/promises"
import * as pdfjs from "pdfjs-dist"
import { fileURLToPath, resolve } from "node:url"
const __dirname = resolve(fileURLToPath(import.meta.url), ".")
console.log(__dirname)
pdfjs.GlobalWorkerOptions.workerSrc = "pdfjs-dist/build/pdf.worker.mjs"
describe("utils/pdf.ts", () => {
test("parseContent", async () => {
const pdfBuffer = await readFile(resolve(__dirname, "./generative-ai.pdf"))
const arrayBuffer = new Uint8Array(pdfBuffer)
const task = await pdfjs.getDocument(arrayBuffer)
const pdf = await task.promise
const page1 = await pdf.getPage(1)
const node = await parseContent(page1)
console.log(node.innerHTML)
expect(1).toBe(1)
})
})
import { describe, test, expect } from "vitest"
import { semanticClip } from "@/utils/utils"
describe("utils/utils.ts", () => {
test("semantic slip", () => {
const text = `Here the beforeEach ensures that the database is reset for each test.
If beforeEach is inside a describe block, it runs for each test in the describe block.
If you only need to run some setup code once, before any tests run, use beforeAll instead.`
expect(semanticClip(text, 80)).toBe(text.slice(0, 73))
expect(semanticClip(text, 174)).toBe(text.slice(0, 169))
expect(semanticClip(text, 300)).toBe(text.slice(0, 267))
})
})
{ {
"extends": "@vue/tsconfig/tsconfig.dom.json", "extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "src/**/*.json"], "include": [
"exclude": ["src/**/__tests__/*"], "env.d.ts",
"src/**/*",
"src/**/*.vue",
"src/**/*.json",
"tests/**/*"
],
"exclude": ["src/**/__tests__/*", "src/manifest.ts"],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
}, },
"types": [ "types": ["@types/chrome"]
"@types/chrome"
]
} }
} }
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
"extends": "@tsconfig/node18/tsconfig.json", "extends": "@tsconfig/node18/tsconfig.json",
"include": [ "include": [
"utils", "utils",
"manifest.ts", "./src/manifest.ts",
"package.json", "package.json",
"vite.config.*", "vite.config.*",
"vitest.config.*", "vitest.config.*",
......
import { resolve } from "path"; import { resolve } from "path"
import type { PluginOption } from "vite"; import type { PluginOption } from "vite"
export default function makeManifest( export default function makeManifest(
manifest: any, manifest: any,
config: { config: {
isDev: boolean; isDev: boolean
assetKeys?: string[]; assetKeys?: string[]
} }
): PluginOption { ): PluginOption {
return { return {
...@@ -13,26 +13,26 @@ export default function makeManifest( ...@@ -13,26 +13,26 @@ export default function makeManifest(
generateBundle(output, bundle) { generateBundle(output, bundle) {
if (config.assetKeys) { if (config.assetKeys) {
for (let key of config.assetKeys) { for (let key of config.assetKeys) {
const id = resolve(manifest[key]).replace(/\\/g, "/"); const id = resolve(manifest[key]).replace(/\\/g, "/")
const chunk = Object.values(bundle).find( const chunk = Object.values(bundle).find(
(b) => b.type === "chunk" && b.facadeModuleId == id (b) => b.type === "chunk" && b.facadeModuleId == id
); )
if (chunk) { if (chunk) {
manifest[key] = chunk.fileName; manifest[key] = chunk.fileName
} }
} }
} }
const content = JSON.stringify(manifest, null, 2); const content = JSON.stringify(manifest, null, 2)
try { try {
this.emitFile({ this.emitFile({
type: "asset", type: "asset",
source: content, source: content,
fileName: "manifest.json", fileName: "manifest.json",
}); })
} catch (e) { } catch (e) {
console.error("manifest-plugin error: ", e); console.error("manifest-plugin error: ", e)
console.error("Failed to emit asset file, possibly a naming conflict"); console.error("Failed to emit asset file, possibly a naming conflict")
} }
}, },
// buildStart() { // buildStart() {
...@@ -48,5 +48,5 @@ export default function makeManifest( ...@@ -48,5 +48,5 @@ export default function makeManifest(
// console.error("Failed to emit asset file, possibly a naming conflict"); // console.error("Failed to emit asset file, possibly a naming conflict");
// } // }
// }, // },
}; }
} }
import { fileURLToPath, URL, resolve as r } from "node:url"; import { fileURLToPath, URL, resolve as r } from "node:url"
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path"
import { defineConfig } from "vite"; import { defineConfig } from "vite"
import vue from "@vitejs/plugin-vue"; import vue from "@vitejs/plugin-vue"
import vueJsx from "@vitejs/plugin-vue-jsx"; import vueJsx from "@vitejs/plugin-vue-jsx"
import vueI18n from "@intlify/unplugin-vue-i18n/vite"; import vueI18n from "@intlify/unplugin-vue-i18n/vite"
import manifest from "./manifest"; import manifest from "./src/manifest"
import makeManifest from "./utils/manifest-plugin"; import makeManifest from "./utils/manifest-plugin"
/// <reference types="vitest" />
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
define: { define: {
__INTLIFY_JIT_COMPILATION__: true, __INTLIFY_JIT_COMPILATION__: true,
__INTLIFY_DROP_MESSAGE_COMPILER__: true, __INTLIFY_DROP_MESSAGE_COMPILER__: true,
__DEV__: process.env.NODE_ENV === "development",
}, },
plugins: [ plugins: [
vue(), vue(),
vueJsx(), vueJsx(),
...@@ -33,19 +38,22 @@ export default defineConfig({ ...@@ -33,19 +38,22 @@ export default defineConfig({
}, },
}, },
build: { build: {
target: ["chrome111"],
emptyOutDir: false, emptyOutDir: false,
assetsDir: "assets", assetsDir: "assets",
outDir: "dist", outDir: "dist",
rollupOptions: { rollupOptions: {
input: { input: {
popup: "popup.html", popup: "src/pages/popup.html",
guide: "guide.html", guide: "src/pages/guide.html",
worker: "src/pages/offscreen.html",
// dev: "src/pages/dev.html",
}, },
output: { output: {
assetFileNames: "[name].[ext]", assetFileNames: "assets/[name].[ext]",
chunkFileNames: "[name]-chunk.js", chunkFileNames: "js/[name]-chunk.js",
entryFileNames: "[name].js", entryFileNames: "js/[name].js",
}, },
}, },
}, },
}); })
import { defineConfig } from "vite"; import { defineConfig } from "vite"
import { fileURLToPath, URL } from "node:url"; import { fileURLToPath, URL } from "node:url"
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path"
import vue from "@vitejs/plugin-vue"; import vue from "@vitejs/plugin-vue"
import vueJsx from "@vitejs/plugin-vue-jsx"; import vueJsx from "@vitejs/plugin-vue-jsx"
import vueI18n from "@intlify/unplugin-vue-i18n/vite"; import vueI18n from "@intlify/unplugin-vue-i18n/vite"
export default defineConfig({ export default defineConfig({
server: { server: {
...@@ -12,6 +12,7 @@ export default defineConfig({ ...@@ -12,6 +12,7 @@ export default defineConfig({
define: { define: {
__INTLIFY_JIT_COMPILATION__: true, __INTLIFY_JIT_COMPILATION__: true,
__INTLIFY_DROP_MESSAGE_COMPILER__: true, __INTLIFY_DROP_MESSAGE_COMPILER__: true,
__DEV__: process.env.NODE_ENV === "development",
}, },
plugins: [ plugins: [
vue(), vue(),
...@@ -38,9 +39,10 @@ export default defineConfig({ ...@@ -38,9 +39,10 @@ export default defineConfig({
content: "./src/content/index.ts", content: "./src/content/index.ts",
}, },
output: { output: {
assetFileNames: "[name].[ext]", assetFileNames: "assets/[name].[ext]",
entryFileNames: "[name].js", chunkFileNames: "js/[name]-chunk.js",
entryFileNames: "js/[name].js",
}, },
}, },
}, },
}); })
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment