Commit 2bc86acf authored by Domi's avatar Domi

feat: chat docs addon

parent 54a246cc
/// <reference types="vite/client" />
interface DocumentPictureInPicture extends EventTarget {
window: Window | null;
requestWindow(option?: { width?: number; height?: number }): Promise<Window>;
window: Window | null
requestWindow(option?: { width?: number; height?: number }): Promise<Window>
}
export declare global {
interface documentPictureInPicture extends DocumentPictureInPicture {}
const __DEV__: boolean
interface Window {
documentPictureInPicture: DocumentPictureInPicture;
trustedTypes: any;
documentPictureInPicture: DocumentPictureInPicture
trustedTypes: any
}
interface Navigator {
userAgentData: {
platform: string;
};
platform: string
}
}
}
export {};
export {}
import * as esbuild from "esbuild";
const isWatch = process.argv.includes("--watch");
import * as esbuild from "esbuild"
const isWatch = process.argv.includes("--watch")
const ctx = await esbuild.context({
entryPoints: {
main: "./src/content/main.ts",
bg: "./src/bg/index.ts",
"js/content-main": "./src/content/main.ts",
"js/pdf.worker": "./src/assets/pdf.worker.js",
},
bundle: true,
format: "iife",
......@@ -12,11 +13,11 @@ const ctx = await esbuild.context({
alias: {
"@": "./src/",
},
});
})
if (isWatch) {
await ctx.watch();
await ctx.watch()
} else {
await ctx.rebuild();
ctx.dispose();
await ctx.rebuild()
ctx.dispose()
}
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -14,10 +14,17 @@
"build:js": "node esbuild.mjs",
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
"zip:win": "7z a anything-copilot.zip .\\dist\\*",
"zip": ""
"zip": "",
"test": "vitest"
},
"dependencies": {
"@types/turndown": "^5.0.4",
"buffer": "^6.0.3",
"file-type": "^18.7.0",
"lodash-es": "^4.17.21",
"mammoth": "^1.6.0",
"pdfjs-dist": "^4.0.269",
"turndown": "^7.1.2",
"vue": "^3.3.4",
"vue-i18n": "^9.7.0"
},
......@@ -37,6 +44,7 @@
"tailwindcss": "^3.3.5",
"typescript": "~5.2.0",
"vite": "^4.4.11",
"vitest": "^1.1.0",
"vue-tsc": "^1.8.19"
}
}
<!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">
<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>
<base href="http://localhost:3000">
</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">
<rect x="75" y="12" width="232" height="58" rx="8" transform="rotate(90 75 12)" fill="#3BABFD"/>
<rect x="157" y="12" width="232" height="59" rx="8" transform="rotate(90 157 12)" fill="#0087E8"/>
<rect x="238" y="12" width="232" height="58" rx="8" transform="rotate(90 238 12)" fill="#005592"/>
<rect x="76" y="12" width="232" height="58" rx="8" transform="rotate(90 76 12)" fill="#FF0000"/>
<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="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>
......@@ -42,6 +42,24 @@
--bg-rgb: 255, 255, 255;
--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;
}
......@@ -60,6 +78,21 @@
--bg-rgb: 0, 0, 0;
--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 "./utilities.css";
#app {
margin: 0 auto;
......@@ -31,3 +32,17 @@ a,
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 { waitMessage, tabUpdated } from "@/utils/ext";
import {
MessageType,
ServiceFunc,
type ParseDocOptions,
type InvokeMessage,
} from "@/types"
import { waitMessage, tabUpdated } from "@/utils/ext"
import { offscreen } from "./offscreen"
async function openPipBackground(url: string) {
const tab = await chrome.tabs.create({
url: url,
});
})
await tabUpdated({ tabId: tab.id!, status: "complete" });
await tabUpdated({ tabId: tab.id!, status: "complete" })
chrome.tabs.sendMessage(tab.id!, {
type: "pip",
......@@ -14,12 +20,13 @@ async function openPipBackground(url: string) {
url: url,
mode: "write-html",
},
});
})
}
/** @deprecated */
async function getContentCss(id: number, url: string) {
const res = await fetch(url);
const text = await res.text();
const res = await fetch(url)
const text = await res.text()
chrome.tabs.sendMessage(id, {
type: "content-css",
......@@ -27,107 +34,150 @@ async function getContentCss(id: number, url: string) {
url: url,
value: text,
},
});
})
}
async function pipLaunch(url: string) {
const tab = await chrome.tabs.create({ url });
const tab = await chrome.tabs.create({ url })
await waitMessage({
tabId: tab.id!,
type: MessageType.contentMount,
});
})
chrome.tabs.sendMessage(tab.id!, {
type: MessageType.pipLaunch,
url: url,
});
})
}
type QueryOptions = {
windowId?: number;
width?: number;
height?: number;
};
windowId?: number
width?: number
height?: number
}
async function getPipWindow(
id: number,
{ windowId, width, height }: QueryOptions
) {
if (windowId) {
const win = await chrome.windows.get(windowId);
const win = await chrome.windows.get(windowId)
chrome.tabs.sendMessage(id, {
type: MessageType.pipWinInfo,
window: win,
});
return win;
})
return win
}
const windows = await chrome.windows.getAll({});
const win = windows.find((w) => w.width === width && w.height === height);
const windows = await chrome.windows.getAll({})
const win = windows.find((w) => w.width === width && w.height === height)
chrome.tabs.sendMessage(id, {
type: MessageType.pipWinInfo,
window: win,
});
return win;
})
return win
}
type MinimizeOptions = {
windowId: number;
};
windowId: number
}
async function minimizePip({ windowId }: MinimizeOptions) {
await chrome.windows.update(windowId, { state: "minimized" });
await chrome.windows.update(windowId, { state: "minimized" })
}
type UpdatePipWinOption = {
windowId: number;
windowInfo: Partial<chrome.windows.UpdateInfo>;
};
windowId: number
windowInfo: Partial<chrome.windows.UpdateInfo>
}
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) {
console.log("bg message: ", message, sender, Date.now());
console.log("bg message: ", message, sender, Date.now())
switch (message?.type) {
case MessageType.bgOpenPip:
openPipBackground(message.url);
break;
openPipBackground(message.url)
break
case "get-content-css":
getContentCss(sender.tab?.id || 0, message.url);
break;
getContentCss(sender.tab?.id || 0, message.url)
break
case MessageType.bgPipLaunch:
pipLaunch(message.url);
break;
pipLaunch(message.url)
break
case MessageType.getPipWinInfo:
getPipWindow(sender.tab?.id!, message.options);
break;
getPipWindow(sender.tab?.id!, message.options)
break
case MessageType.updateWindow:
updateWindow(message.options);
break;
updateWindow(message.options)
break
case MessageType.removeWindow:
chrome.windows.remove(message.options.windowId);
break;
chrome.windows.remove(message.options.windowId)
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() {
const { pipWindowId } = await chrome.storage.local.get({ pipWindowId: null });
if (!pipWindowId) return;
const windowInfo = await chrome.windows.get(pipWindowId);
if (!windowInfo) return;
const { pipWindowId } = await chrome.storage.local.get({ pipWindowId: null })
if (!pipWindowId) return
const windowInfo = await chrome.windows.get(pipWindowId)
if (!windowInfo) return
await chrome.windows.update(pipWindowId, {
state: windowInfo.state == "minimized" ? "normal" : "minimized",
});
})
}
function handleCommand(command: string) {
console.log("command: ", command);
console.log("command: ", command)
switch (command) {
case "toggleMinimize":
handleToggleMinimize();
break;
handleToggleMinimize()
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">
import { ref, computed, watch, onMounted } from "vue"
import { ref, computed, watch, onMounted, onUnmounted } from "vue"
import IconMinimize from "@/components/icons/IconMinimize.vue"
import IconSplitRight from "@/components/icons/IconSplitscreenRight.vue"
import IconClose from "@/components/icons/IconClose.vue"
......@@ -8,7 +8,6 @@ import { pipWindow } from "@/store"
import { throttle } from "lodash-es"
import IconRefresh from "./icons/IconRefresh.vue"
import { dispatchContentEvent } from "@/content/event"
import { onUnmounted } from "vue"
import { useI18n } from "@/utils/i18n"
import IconHide from "./icons/IconHide.vue"
......@@ -71,11 +70,11 @@ watch(open, (value, oldValue, onCleanup) => {
const menuPointerEnter = () => {
clearTimeout(timer.value)
timer.value = setTimeout(() => (open.value = true), 200)
timer.value = window.setTimeout(() => (open.value = true), 200)
}
const menuPointerLeave = () => {
clearTimeout(timer.value)
timer.value = setTimeout(() => (open.value = false), 200)
timer.value = window.setTimeout(() => (open.value = false), 200)
}
const btnPointerEnter = () => {
......@@ -86,7 +85,7 @@ const btnPointerLeave = () => {
if (pinOpen.value) {
return
}
timer.value = setTimeout(() => (open.value = false), 350)
timer.value = window.setTimeout(() => (open.value = false), 350)
}
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>
This diff is collapsed.
<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">
import { pipLauncher } from "@/store";
import PipLauncher from "@/components/PipLauncher.vue";
import { pipLauncher } from "@/store"
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>
<template>
......@@ -12,6 +13,8 @@ const { t } = useI18n();
v-if="pipLauncher.visible"
@close="pipLauncher.visible = false"
/>
<ChatDocsAddon />
</template>
<style scoped></style>
......@@ -3,6 +3,7 @@ import Multitasking from "@/components/Multitasking.vue"
import PipSplash from "@/components/PipSplash.vue"
import { pipLoading } from "@/store"
import LoadingBar from "@/components/LoadingBar.vue"
import ChatDocsAddon from "@/components/chatdocs/ChatDocsAddon.vue"
</script>
<template>
......@@ -21,6 +22,8 @@ import LoadingBar from "@/components/LoadingBar.vue"
>
<LoadingBar />
</div>
<ChatDocsAddon />
</template>
<style scoped></style>
......@@ -8,6 +8,7 @@ import {
addContentEventListener,
removeContentEventListener,
} from "@/content/event"
import { contentService } from "@/utils/service"
// import { PipEventName } from "@/types/pip"
function handleMessage(
......@@ -41,6 +42,9 @@ function handleMessage(
pipWindowId: message.window.id,
})
break
case MessageType.invokeResponse:
contentService.handleMessage(message)
break
}
}
......
......@@ -14,7 +14,7 @@ export function mount(App: Component, doc = document) {
const link = doc.createElement("link")
link.rel = "stylesheet"
link.href = chrome.runtime?.getURL("/index.css")
link.href = chrome.runtime?.getURL("/assets/index.css")
root.append(link)
root.append(appContainer)
doc.documentElement.append(outter)
......
const contentCss = "index.css";
const __DEV__ = process.env.NODE_ENV == "development"
const contentCss = "/assets/index.css"
const manifest = {
manifest_version: 3,
......@@ -16,7 +18,7 @@ const manifest = {
32: "logo.png",
},
default_title: "__MSG_short_name__",
default_popup: "popup.html",
default_popup: "src/pages/popup.html",
},
default_locale: "en",
icons: {
......@@ -33,18 +35,18 @@ const manifest = {
content_scripts: [
{
matches: ["<all_urls>"],
js: ["main.js"],
js: ["/js/content-main.js"],
run_at: "document_start",
world: "MAIN",
},
{
matches: ["<all_urls>"],
js: ["content.js"],
js: ["/js/content.js"],
run_at: "document_start",
},
],
options_page: "guide.html",
permissions: ["tabs", "scripting", "activeTab", "storage"],
options_page: __DEV__ ? "/src/pages/guide.html" : "",
permissions: ["tabs", "scripting", "activeTab", "storage", "offscreen"],
host_permissions: ["<all_urls>"],
minimum_chrome_version: "111",
commands: {
......@@ -58,16 +60,15 @@ const manifest = {
},
web_accessible_resources: [
{
resources: [contentCss],
resources: [contentCss, "logo.svg"],
matches: ["<all_urls>"],
},
],
content_security_policy:
process.env.NODE_ENV == "development"
? {
extension_pages: `script-src 'self' http://localhost:3000;`,
}
: undefined,
};
content_security_policy: __DEV__
? {
extension_pages: `script-src 'self' http://localhost:3000;`,
}
: undefined,
}
export default manifest;
export default manifest
<script setup lang="ts">
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 PipWindowActions from "@/components/popup/PipWindowActions.vue"
import IconThumbUp from "@/components/icons/IconThumbUp.vue"
......@@ -9,7 +9,6 @@ import IconGithub from "@/components/icons/IconGithub.vue"
import IconDiscord from "@/components/icons/IconDiscord.vue"
import IconXLogo from "@/components/icons/IconXLogo.vue"
import { useI18n } from "@/utils/i18n"
import { getStoreUrl } from "@/utils/store"
const { t } = useI18n()
......
......@@ -8,6 +8,6 @@
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/pages/guide.ts"></script>
<script type="module" src="./guide.ts"></script>
</body>
</html>
import "@/assets/main.css";
import "@/assets/main.css"
import { createApp } from "vue";
import Guide from "./Popup.vue";
import { createApp } from "vue"
import Guide from "./Popup.vue"
import { i18n } from "@/utils/i18n"
createApp(Guide).mount("#app");
const app = createApp(Guide)
function injectContent() {
const mainScript = document.createElement("script");
mainScript.src = "/main.js";
const script = document.createElement("script");
script.src = "/content.js";
document.head.append(mainScript);
document.head.append(script);
}
injectContent();
app.use(i18n)
app.mount("#app")
<!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 @@
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/pages/popup.ts"></script>
<script type="module" src="./popup.ts"></script>
</body>
</html>
import "@/assets/main.css";
import "@/assets/main.css"
import { createApp } from "vue";
import Popup from "./Popup.vue";
import { i18n } from "@/utils/i18n";
import { createApp } from "vue"
import Popup from "./Popup.vue"
import { i18n } from "@/utils/i18n"
const app = createApp(Popup);
const app = createApp(Popup)
app.use(i18n);
app.mount("#app");
\ No newline at end of file
app.use(i18n)
app.mount("#app")
import { reactive, ref } from "vue";
export const contentCss = ref("");
export const pipLauncher = reactive({
visible: false,
});
import { reactive, ref } from "vue"
/** popup state */
export const items = reactive([
{
url: "https://chat.openai.com/",
img: "/chatgpt.svg",
img: "/img/chatgpt.svg",
title: "ChatGPT - OpenAI",
},
{
url: "https://bard.google.com/",
img: "/bard.svg",
img: "/img/bard.svg",
title: "Bard - Google AI",
},
{
url: "https://claude.ai/",
img: "/claude-ai.svg",
img: "/img/claude-ai.svg",
title: "Claude",
},
{
url: "https://tiktok.com/",
img: "/tiktok.svg",
img: "/img/tiktok.svg",
title: "Tiktok",
},
]);
])
export const contentCss = ref("")
export const pipLauncher = reactive({
visible: false,
})
export const pipWindow = reactive({
id: 0,
window: null as Window | null,
windowsWindow: null as chrome.windows.Window | null,
tab: null as chrome.tabs.Tab | null,
});
})
export const pipLoading = reactive({
isLoading: 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 {
pipWinInfo = "pip-win-info",
updateWindow = "update-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
* */
......@@ -6,9 +6,9 @@ export function query(selector: Selector) {
if (typeof selector === "string") {
// Check for /deep/ combinator in the selector
if (selector.includes("/deep/")) {
return queryShadowDom(document.documentElement, selector.split("/deep/"));
return queryShadowDom(document.documentElement, selector.split("/deep/"))
} else {
return document.querySelector(selector);
return document.querySelector(selector)
}
} else if (selector.xpath) {
// Handle XPath selector
......@@ -18,88 +18,110 @@ export function query(selector: Selector) {
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
);
return result.singleNodeValue as Element;
)
return result.singleNodeValue as Element
}
return null;
return null
}
function queryShadowDom(el: Element, parts: string[]) {
return null;
function queryShadowDom(
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[]) {
for (let selector of selectors) {
const result = query(selector);
const result = query(selector)
if (result) {
return result;
return result
}
}
return null;
return null
}
export function copyStyleSheets(pipWindow: Window, document: Document) {
[...document.styleSheets].forEach((styleSheet) => {
;[...document.styleSheets].forEach((styleSheet) => {
try {
const cssRules = [...styleSheet.cssRules]
.map((rule) => rule.cssText)
.join("");
const style = document.createElement("style");
.join("")
const style = document.createElement("style")
style.textContent = cssRules;
pipWindow.document.head.appendChild(style);
style.textContent = cssRules
pipWindow.document.head.appendChild(style)
} catch (e) {
const link = document.createElement("link");
const link = document.createElement("link")
link.rel = "stylesheet";
link.type = styleSheet.type;
link.media = styleSheet.media as any;
link.href = styleSheet.href as string;
pipWindow.document.head.appendChild(link);
link.rel = "stylesheet"
link.type = styleSheet.type
link.media = styleSheet.media as any
link.href = styleSheet.href as string
pipWindow.document.head.appendChild(link)
}
});
})
}
export function getDomNonce(doc: Document) {
const nonce = { style: "", script: "" };
const nonce = { style: "", script: "" }
const elements = doc.querySelectorAll<
HTMLScriptElement | HTMLStyleElement | HTMLLinkElement
>("[nonce");
>("[nonce")
const isLink = (el: HTMLElement): el is HTMLLinkElement =>
el.nodeName == "LINK";
el.nodeName == "LINK"
for (let element of elements) {
const code = element.nonce;
if (!code) continue;
const code = element.nonce
if (!code) continue
if (element.nodeName == "SCRIPT" && !nonce.script) {
nonce.script = code;
continue;
nonce.script = code
continue
}
if (isLink(element) && element.as == "script" && !nonce.script) {
nonce.script = code;
continue;
nonce.script = code
continue
}
if (element.nodeName == "STYLE" && !nonce.style) {
nonce.style = code;
continue;
nonce.style = code
continue
}
if (
isLink(element) &&
element.rel?.search("stylesheet") > -1 &&
!nonce.style
) {
nonce.style = code;
continue;
nonce.style = code
continue
}
if (isLink(element) && element.as == "style" && !nonce.style) {
nonce.style = code;
continue;
nonce.style = code
continue
}
}
return nonce;
return nonce
}
export function replaceHtmlNonce(
......@@ -108,29 +130,65 @@ export function replaceHtmlNonce(
) {
const replacer = (match: string, p1: string, p2: string) => {
const isScript =
p1 == "script" || (p1 == "link" && match.search("script") > -1);
p1 == "script" || (p1 == "link" && match.search("script") > -1)
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) {
r = match.replace(p2, nonce.script);
r = match.replace(p2, nonce.script)
}
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);
return txt;
let txt = html.replace(/<(script|style|link)\s[^>]*nonce="(.+?)"/g, replacer)
return txt
}
export function removePrerenderRules(doc: Document) {
const rules = doc.querySelectorAll('script[type="speculationrules"]');
const rules = doc.querySelectorAll('script[type="speculationrules"]')
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>({
};
chrome.runtime.onMessage.addListener(handleMessage);
if (timeout) {
timer = setTimeout(() => {
timer = window.setTimeout(() => {
chrome.runtime.onMessage.removeListener(handleMessage);
reject();
}, timeout);
......@@ -130,3 +130,45 @@ export async function checkContent(tabId: number) {
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 = {
9: "36px",
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: [],
};
}
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",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "src/**/*.json"],
"exclude": ["src/**/__tests__/*"],
"include": [
"env.d.ts",
"src/**/*",
"src/**/*.vue",
"src/**/*.json",
"tests/**/*"
],
"exclude": ["src/**/__tests__/*", "src/manifest.ts"],
"compilerOptions": {
"composite": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"types": [
"@types/chrome"
]
"types": ["@types/chrome"]
}
}
......@@ -2,7 +2,7 @@
"extends": "@tsconfig/node18/tsconfig.json",
"include": [
"utils",
"manifest.ts",
"./src/manifest.ts",
"package.json",
"vite.config.*",
"vitest.config.*",
......
import { resolve } from "path";
import type { PluginOption } from "vite";
import { resolve } from "path"
import type { PluginOption } from "vite"
export default function makeManifest(
manifest: any,
config: {
isDev: boolean;
assetKeys?: string[];
isDev: boolean
assetKeys?: string[]
}
): PluginOption {
return {
......@@ -13,26 +13,26 @@ export default function makeManifest(
generateBundle(output, bundle) {
if (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(
(b) => b.type === "chunk" && b.facadeModuleId == id
);
)
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 {
this.emitFile({
type: "asset",
source: content,
fileName: "manifest.json",
});
})
} catch (e) {
console.error("manifest-plugin error: ", e);
console.error("Failed to emit asset file, possibly a naming conflict");
console.error("manifest-plugin error: ", e)
console.error("Failed to emit asset file, possibly a naming conflict")
}
},
// buildStart() {
......@@ -48,5 +48,5 @@ export default function makeManifest(
// console.error("Failed to emit asset file, possibly a naming conflict");
// }
// },
};
}
}
import { fileURLToPath, URL, resolve as r } from "node:url";
import { dirname, resolve } from "node:path";
import { fileURLToPath, URL, resolve as r } from "node:url"
import { dirname, resolve } from "node:path"
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";
import vueI18n from "@intlify/unplugin-vue-i18n/vite";
import manifest from "./manifest";
import makeManifest from "./utils/manifest-plugin";
import { defineConfig } from "vite"
import vue from "@vitejs/plugin-vue"
import vueJsx from "@vitejs/plugin-vue-jsx"
import vueI18n from "@intlify/unplugin-vue-i18n/vite"
import manifest from "./src/manifest"
import makeManifest from "./utils/manifest-plugin"
/// <reference types="vitest" />
// https://vitejs.dev/config/
export default defineConfig({
define: {
__INTLIFY_JIT_COMPILATION__: true,
__INTLIFY_DROP_MESSAGE_COMPILER__: true,
__DEV__: process.env.NODE_ENV === "development",
},
plugins: [
vue(),
vueJsx(),
......@@ -33,19 +38,22 @@ export default defineConfig({
},
},
build: {
target: ["chrome111"],
emptyOutDir: false,
assetsDir: "assets",
outDir: "dist",
rollupOptions: {
input: {
popup: "popup.html",
guide: "guide.html",
popup: "src/pages/popup.html",
guide: "src/pages/guide.html",
worker: "src/pages/offscreen.html",
// dev: "src/pages/dev.html",
},
output: {
assetFileNames: "[name].[ext]",
chunkFileNames: "[name]-chunk.js",
entryFileNames: "[name].js",
assetFileNames: "assets/[name].[ext]",
chunkFileNames: "js/[name]-chunk.js",
entryFileNames: "js/[name].js",
},
},
},
});
})
import { defineConfig } from "vite";
import { fileURLToPath, URL } from "node:url";
import { dirname, resolve } from "node:path";
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";
import vueI18n from "@intlify/unplugin-vue-i18n/vite";
import { defineConfig } from "vite"
import { fileURLToPath, URL } from "node:url"
import { dirname, resolve } from "node:path"
import vue from "@vitejs/plugin-vue"
import vueJsx from "@vitejs/plugin-vue-jsx"
import vueI18n from "@intlify/unplugin-vue-i18n/vite"
export default defineConfig({
server: {
......@@ -12,6 +12,7 @@ export default defineConfig({
define: {
__INTLIFY_JIT_COMPILATION__: true,
__INTLIFY_DROP_MESSAGE_COMPILER__: true,
__DEV__: process.env.NODE_ENV === "development",
},
plugins: [
vue(),
......@@ -38,9 +39,10 @@ export default defineConfig({
content: "./src/content/index.ts",
},
output: {
assetFileNames: "[name].[ext]",
entryFileNames: "[name].js",
assetFileNames: "assets/[name].[ext]",
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