7 | children?: React.ReactNode
8 | }
9 |
10 | export function Header({ containerRef, children }: HeaderProps) {
11 | const scrollPosition = useScrollPosition(containerRef)
12 | // Calculate styles based on scroll position
13 | const isScrolled = scrollPosition > 50
14 |
15 | const headerStyle = isScrolled
16 | ? "lg:text-3xl md:text-xl text-2xl"
17 | : "lg:text-4xl md:text-3xl text-2xl"
18 |
19 | // Dynamically calculate height for the logo image
20 | const logoSize = isScrolled
21 | ? "h-12 w-12"
22 | : "h-20 w-20 sm:h-22 sm:w-22 md:h-24 md:w-24 lg:h-28 lg:w-28"
23 |
24 | const taglineSize = isScrolled
25 | ? "lg:text-lg md:text-base text-sm"
26 | : "lg:text-xl md:text-base text-sm"
27 |
28 | // Reduce padding when scrolled
29 | const padding = isScrolled ? "py-1" : "py-2"
30 |
31 | const menuTop = isScrolled ? "top-3" : "top-6"
32 |
33 | return (
34 |
37 |
38 |
41 |
46 |
47 |
48 |
51 | A.R.I.A.
52 | (Aria)
53 |
54 |
57 | Your AI Research Assistant
58 |
59 |
60 |
61 |
62 | {children}
63 |
64 |
65 | )
66 | }
67 |
--------------------------------------------------------------------------------
/src/views/features/Notification.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | export function Notification() {
4 | const notification = (
5 | <>
6 | This is the final version compatible with Zotero 6. Future releases will
7 | support only Zotero 7.{" "}
8 | {
11 | Zotero.launchURL(
12 | `https://github.com/lifan0127/ai-research-assistant/releases`,
13 | )
14 | }}
15 | className="border-none bg-transparent m-0 p-0 text-black underline"
16 | >
17 | Please find the latest release here.
18 |
19 | >
20 | )
21 |
22 | return (
23 |
28 | {notification}
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/src/views/features/infoPanel/FAQ.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Link } from '../../components/buttons/Link'
3 |
4 | interface QuestionProps {
5 | question: string
6 | answer: string | JSX.Element
7 | }
8 |
9 | function Question({ question, answer }: QuestionProps) {
10 | return (
11 |
12 |
{question}
13 |
{answer}
14 |
15 | )
16 | }
17 |
18 | const questions = [
19 | {
20 | question: 'I cannot use Aria, even though I have a ChatGPT Plus subscription.',
21 | answer: (
22 |
23 | ChatGPT Plus and GPT APIs are different offerings from OpenAI. Aria requires
24 | the GPT APIs to work. Please make sure you have a valid OpenAI API key:{' '}
25 | , and note that
26 | OpenAI may limit certain GPT models to paying customers only.
27 |
28 | ),
29 | },
30 | {
31 | question: 'Can I use an OpenAI proxy server or the Azure OpenAI?',
32 | answer:
33 | 'If you access the OpenAI APIs through a proxy server, you can update the OpenAI API base URL accordingly in Preferences > Aria. As for Azure OpenAI, it is unfortunately not supported at the moment.',
34 | },
35 | {
36 | question: 'I encountered an error when using Aria. What should I do?',
37 | answer: (
38 |
39 | Common errors include: 1. invalid OpenAI API key, 2. invalid API base URL or proxy server error, 3. no access to
40 | certain GPT model(s). Please feel free to{' '}
41 | on the GitHub
42 | repo for assistance.
43 |
44 | ),
45 | },
46 | {
47 | question: 'Does Aria support languages other than English?',
48 | answer:
49 | 'Aria officially only supports English. However, as the underlying GPT models are multilingual, you may have success in conversing with Aria in other languages',
50 | },
51 | {
52 | question: "What if I have a question that isn't answered here?",
53 | answer: (
54 |
55 | You are welcome to{' '}
56 | or{' '}
57 | on the
58 | GitHub repo.
59 |
60 | ),
61 | },
62 | ]
63 |
64 | export function FAQ() {
65 | return (
66 |
67 | {questions.map(({ question, answer }) => (
68 |
69 |
70 |
71 | ))}
72 |
73 | )
74 | }
75 |
--------------------------------------------------------------------------------
/src/views/features/infoPanel/InfoPanel.tsx:
--------------------------------------------------------------------------------
1 | import React, { Fragment, useState } from 'react'
2 | import { ButtonGroup } from '../../components/buttons/ButtonGroup'
3 |
4 | interface InfoPanelProps {
5 | promptLibrary: JSX.Element
6 | faq: JSX.Element
7 | }
8 |
9 | export function InfoPanel({ promptLibrary, faq }: InfoPanelProps) {
10 | const [selected, setSelected] = useState('promptLibrary')
11 | const groups = [
12 | {
13 | key: 'promptLibrary',
14 | label: 'Prompt Library',
15 | component: promptLibrary,
16 | onClick: () => setSelected('promptLibrary'),
17 | },
18 | {
19 | key: 'faq',
20 | label: 'FAQ',
21 | component: faq,
22 | onClick: () => setSelected('faq'),
23 | },
24 | ]
25 | return (
26 |
27 |
28 |
29 | {groups.map(({ key, component }) => {
30 | return selected === key ? {component} : null
31 | })}
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/src/views/features/input/States.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { XCircleIcon } from "@heroicons/react/24/outline"
3 | import { SelectionIcon } from "../../icons/zotero"
4 | import {
5 | States,
6 | SelectedImage,
7 | StateName,
8 | selectionConfig,
9 | } from "../../../models/utils/states"
10 | import { useStates } from "../../../hooks/useStates"
11 |
12 | interface ChipProps {
13 | children: React.ReactNode
14 | onDelete: () => void
15 | name: StateName
16 | }
17 |
18 | function Chip({ children, onDelete, name }: ChipProps) {
19 | const backgroundColor = selectionConfig[name].backgroundColor
20 | return (
21 |
25 | {children}
26 |
27 |
31 |
32 |
33 | )
34 | }
35 |
36 | function SelectionContainer({
37 | states,
38 | name,
39 | }: {
40 | states: ReturnType
41 | name: StateName
42 | }) {
43 | const selections = states.states[name]
44 | if (selections.length === 0) {
45 | return null
46 | }
47 |
48 | return (
49 |
50 |
51 | {name}
52 |
53 | {selections.map((selection) => {
54 | const { id, type, title } = selection
55 | return name === "images" ? (
56 |
60 |
64 |
{id}
65 |
66 |
67 | states.remove(name, selection)}
69 | className="text-gray-400 hover:text-black"
70 | />
71 |
72 |
73 |
74 |
79 |
80 |
81 | ) : (
82 |
states.remove(name, selection)}
85 | name={name}
86 | >
87 |
88 |
89 |
90 | {title}
91 |
92 | )
93 | })}
94 | {selections.length > 2 && (
95 |
states.removeAll(name)}
98 | >
99 | Remove all {name}
100 |
101 | )}
102 |
103 | )
104 | }
105 |
106 | interface StatesProps {
107 | states: ReturnType
108 | }
109 |
110 | export function States({ states }: StatesProps) {
111 | const stateNames: StateName[] = [
112 | "creators",
113 | "tags",
114 | "items",
115 | "collections",
116 | "images",
117 | ]
118 | return (
119 |
120 | {stateNames.map((name) => (
121 |
122 | ))}
123 |
124 | )
125 | }
126 |
--------------------------------------------------------------------------------
/src/views/features/messages/MessageControl.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { marked } from "marked"
3 | import {
4 | BotMessageProps,
5 | UserMessageProps,
6 | } from "../../../typings/legacyMessages"
7 | import { CopyButton } from "../../components/buttons/CopyButton"
8 | import { NoteButton } from "../../components/buttons/NoteButton"
9 | import { AnnotateButton } from "../../components/buttons/AnnotateButton"
10 |
11 | function defaultCopy(input: any) {
12 | const textContent = "" + JSON.stringify(input, null, 2) + " "
13 | const htmlContent = marked(textContent)
14 | return new ztoolkit.Clipboard()
15 | .addText(textContent, "text/unicode")
16 | .addText(htmlContent, "text/html")
17 | .copy()
18 | }
19 |
20 | interface MessageControlProps
21 | extends Pick {
22 | states?: UserMessageProps["states"]
23 | }
24 |
25 | export function MessageControl({
26 | id,
27 | content,
28 | copyId,
29 | setCopyId,
30 | states,
31 | }: MessageControlProps) {
32 | const Widget = Markdown
33 |
34 | return (
35 | <>
36 | {Widget.buttonDefs.map(({ name, utils }, index) => {
37 | switch (name) {
38 | case "COPY": {
39 | return (
40 |
49 | )
50 | }
51 | case "NOTE": {
52 | return (
53 |
60 | )
61 | }
62 | case "ANNOTATION": {
63 | return (
64 |
71 | )
72 | }
73 | default: {
74 | return null
75 | }
76 | }
77 | })}
78 | >
79 | )
80 | }
81 |
--------------------------------------------------------------------------------
/src/views/features/messages/actions/Markdown.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react"
2 | import MarkdownReact from "marked-react"
3 | import { marked } from "marked"
4 | import { config } from "../../../../../package.json"
5 | import {
6 | annotationButtonDef,
7 | copyButtonDef,
8 | noteButtonDef,
9 | } from "../../../components/buttons/types"
10 | import { customMarkdownRenderer } from "../../../utils/markdown"
11 | import { Control } from "../../../components/types"
12 |
13 | export interface Content {
14 | status: "COMPLETED" | "IN_PROGRESS"
15 | text: string
16 | }
17 |
18 | export interface Props {
19 | content: Content
20 | control: Control
21 | }
22 |
23 | export function Component({
24 | content: { status, text },
25 | control: { scrollToEnd },
26 | }: Props) {
27 | useEffect(() => {
28 | scrollToEnd()
29 | }, [text])
30 |
31 | return (
32 |
33 | {text}
34 |
35 | )
36 | }
37 |
38 | export function compileContent({ input: { content: textContent } }: Props) {
39 | const htmlContent = marked(textContent)
40 | return { textContent, htmlContent }
41 | }
42 |
43 | function copy(props: Props) {
44 | const { textContent, htmlContent } = compileContent(props)
45 | return new ztoolkit.Clipboard()
46 | .addText(textContent, "text/unicode")
47 | .addText(htmlContent, "text/html")
48 | .copy()
49 | }
50 |
51 | async function createNote(props: Props) {
52 | const { htmlContent } = compileContent(props)
53 | const note =
54 | '' +
55 | `
New Note from ${config.addonName} - ${new Date().toLocaleString()} ` +
56 | htmlContent +
57 | ""
58 | return note
59 | }
60 |
61 | function createAnnotation(props: Props) {
62 | const { textContent } = compileContent(props)
63 | return textContent
64 | }
65 |
66 | export const buttonDefs = [
67 | {
68 | name: "COPY",
69 | utils: { copy },
70 | } as copyButtonDef,
71 | {
72 | name: "NOTE",
73 | utils: { createNote },
74 | } as noteButtonDef,
75 | {
76 | name: "ANNOTATION",
77 | utils: { createAnnotation },
78 | } as annotationButtonDef,
79 | ]
80 |
--------------------------------------------------------------------------------
/src/views/features/messages/steps/ActionStep.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react"
2 | import { ActionStepContent, ActionStepControl } from "../../../../typings/steps"
3 | import { CodeHighlighter } from "../../../components/code/CodeHighlighter"
4 | import stringify from "json-stringify-pretty-compact"
5 | import { SearchAction } from "../actions/SearchAction"
6 | import { FileAction } from "../actions/FileAction"
7 | import { QAAction } from "../actions/QAAction"
8 | import { RetryAction } from "../actions/RetryAction"
9 |
10 | export interface ActionStepProps {
11 | content: ActionStepContent
12 | control: ActionStepControl
13 | }
14 |
15 | export function ActionStep({ content, control }: ActionStepProps) {
16 | switch (content.params.action.type) {
17 | case "search": {
18 | return
19 | }
20 | case "file": {
21 | return
22 | }
23 | case "qa": {
24 | return
25 | }
26 | case "retry": {
27 | return
28 | }
29 | default: {
30 | return
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/views/features/messages/steps/ErrorStep.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef } from "react"
2 | import { ErrorStepContent, ErrorStepControl } from "../../../../typings/steps"
3 | import { Message as OpenAIMessage } from "openai/resources/beta/threads/messages"
4 | import { DocumentIcon } from "@heroicons/react/24/outline"
5 | import * as Markdown from "../actions/Markdown"
6 | import { parsePartialJson } from "../../../../utils/parsers"
7 | import * as Error from "../actions/ErrorAction"
8 |
9 | export interface ErrorStepProps {
10 | content: ErrorStepContent
11 | control: ErrorStepControl
12 | }
13 |
14 | export function ErrorStep({ content, control }: ErrorStepProps) {
15 | const { params: error } = content
16 |
17 | return
18 | }
19 |
--------------------------------------------------------------------------------
/src/views/features/messages/steps/ToolStep.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, useState } from "react"
2 | import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/20/solid"
3 | import stringify from "json-stringify-pretty-compact"
4 | import { ToolStepContent, ToolStepControl } from "../../../../typings/steps"
5 | import { ZoteroIcon } from "../../../icons/zotero"
6 | import { runFunctionTool } from "../../../../models/tools"
7 | import { CodeHighlighter } from "../../../components/code/CodeHighlighter"
8 | import { tools } from "../../../../models/tools"
9 | import { CSSTransition } from "react-transition-group"
10 |
11 | export interface ToolStepProps {
12 | content: ToolStepContent
13 | control: ToolStepControl
14 | }
15 |
16 | export function ToolStep({ content, control }: ToolStepProps) {
17 | const {
18 | id,
19 | messageId,
20 | status,
21 | params: { id: toolCallId, name, parameters, output },
22 | } = content
23 | const [expanded, setExpanded] = useState(false)
24 | const ref = useRef(null)
25 | const toolInfo =
26 | tools[name as "search_tag" | "search_creator" | "search_item"]
27 | const { scrollToEnd, pauseScroll, addFunctionCallOutput, updateBotStep } =
28 | control
29 |
30 | useEffect(() => {
31 | const runTool = async () => {
32 | const output = (await runFunctionTool(name, parameters)) || "No output"
33 | updateBotStep(messageId, id, {
34 | status: "COMPLETED",
35 | params: { name, parameters, output },
36 | } as Omit)
37 | addFunctionCallOutput(toolCallId, output)
38 | // scrollToEnd()
39 | }
40 | if (!output) {
41 | runTool()
42 | }
43 | }, [name, parameters])
44 |
45 | const handleClick = (event: React.MouseEvent) => {
46 | event.preventDefault()
47 | setExpanded(!expanded)
48 | pauseScroll()
49 | }
50 |
51 | return (
52 |
105 | )
106 | }
107 |
--------------------------------------------------------------------------------
/src/views/features/messages/steps/WorkflowStep.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react"
2 | import {
3 | WorkflowStepContent,
4 | WorkflowStepControl,
5 | } from "../../../../typings/steps"
6 | import { CodeHighlighter } from "../../../components/code/CodeHighlighter"
7 | import stringify from "json-stringify-pretty-compact"
8 | import { SearchWorkflow } from "../workflows/SearchWorkflow"
9 | import { QAWorkflow } from "../workflows/QAWorkflow"
10 |
11 | export interface WorkflowStepProps {
12 | content: WorkflowStepContent
13 | control: WorkflowStepControl
14 | }
15 |
16 | export function WorkflowStep({ content, control }: WorkflowStepProps) {
17 | switch (content.params.workflow.type) {
18 | case "search": {
19 | return
20 | }
21 | case "qa": {
22 | return
23 | }
24 | default: {
25 | return
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/views/features/messages/workflows/QAWorkflow.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react"
2 | import {
3 | QAWorkflowStepContent,
4 | WorkflowStepControl,
5 | SearchActionStepContent,
6 | } from "../../../../typings/steps"
7 | import { workflow as log } from "../../../../utils/loggers"
8 | import type { recursiveSearchAndCompileResults } from "../../../../apis/zotero/search"
9 | import { invoke } from "lodash"
10 |
11 | interface QAWorkflowProps {
12 | content: QAWorkflowStepContent
13 | control: WorkflowStepControl
14 | }
15 |
16 | export function QAWorkflow({
17 | content,
18 | control: { scrollToEnd, pauseScroll, getBotStep, addBotStep, updateBotStep },
19 | }: QAWorkflowProps) {
20 | log("QAWorkflow", content)
21 | useEffect(() => {
22 | if (content.status !== "COMPLETED") {
23 | if (!content.params.searchResultsStepId) {
24 | addBotStep(content.messageId, {
25 | type: "ACTION_STEP",
26 | params: {
27 | action: {
28 | type: "search",
29 | mode: content.params.workflow.input.fulltext ? "fulltext" : "qa",
30 | },
31 | workflow: {
32 | type: "qa",
33 | messageId: content.messageId,
34 | stepId: content.id,
35 | },
36 | },
37 | })
38 | } else if (content.params.workflow.input.fulltext) {
39 | if (content.params.searchResultsCount === 0) {
40 | addBotStep(content.messageId, {
41 | type: "ACTION_STEP",
42 | params: {
43 | action: {
44 | type: "retry",
45 | input: {
46 | message: "No search results found.",
47 | prompt:
48 | "The search query didn't return any results. Please revise and try again.",
49 | },
50 | },
51 | // context: content.params.context,
52 | workflow: {
53 | type: "qa",
54 | messageId: content.messageId,
55 | stepId: content.id,
56 | },
57 | },
58 | })
59 | } else {
60 | if (!content.params.indexed) {
61 | addBotStep(content.messageId, {
62 | type: "ACTION_STEP",
63 | params: {
64 | action: {
65 | type: "file",
66 | input: {
67 | // files: searchResults.results,
68 | searchResultsStepId: content.params.searchResultsStepId,
69 | },
70 | },
71 | workflow: {
72 | type: "qa",
73 | messageId: content.messageId,
74 | stepId: content.id,
75 | },
76 | },
77 | })
78 | } else {
79 | addBotStep(content.messageId, {
80 | type: "ACTION_STEP",
81 | params: {
82 | action: {
83 | type: "qa",
84 | input: {
85 | question: content.params.workflow.input.question,
86 | fulltext: content.params.workflow.input.fulltext,
87 | },
88 | },
89 | workflow: {
90 | type: "qa",
91 | messageId: content.messageId,
92 | stepId: content.id,
93 | },
94 | },
95 | })
96 | }
97 | }
98 | } else {
99 | log("QAWorkflow complete", { content })
100 | updateBotStep(content.messageId, content.id, { status: "COMPLETED" })
101 | }
102 | }
103 | }, [content])
104 |
105 | const { messageId, id, status, params } = content
106 |
107 | return null
108 | // return (
109 | //
110 | //
SearchWorkflowStep
111 | //
Status: {status}
112 | //
MessageId: {messageId}
113 | //
id: {id}
114 | //
Params: {JSON.stringify(params)}
115 | //
116 | // )
117 | }
118 |
--------------------------------------------------------------------------------
/src/views/features/messages/workflows/SearchWorkflow.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react"
2 | import {
3 | SearchActionStepContent,
4 | SearchWorkflowStepContent,
5 | WorkflowStepControl,
6 | } from "../../../../typings/steps"
7 | import { workflow as log } from "../../../../utils/loggers"
8 | import type { recursiveSearchAndCompileResults } from "../../../../apis/zotero/search"
9 | import { invoke } from "lodash"
10 |
11 | interface SearchWorkflowProps {
12 | content: SearchWorkflowStepContent
13 | control: WorkflowStepControl
14 | }
15 |
16 | export function SearchWorkflow({
17 | content,
18 | control: { scrollToEnd, pauseScroll, getBotStep, addBotStep, updateBotStep },
19 | }: SearchWorkflowProps) {
20 | useEffect(() => {
21 | async function invokeSearchAction() {
22 | await addBotStep(content.messageId, {
23 | type: "ACTION_STEP",
24 | params: {
25 | action: {
26 | type: "search",
27 | mode: "search",
28 | },
29 | workflow: {
30 | type: "search",
31 | messageId: content.messageId,
32 | stepId: content.id,
33 | },
34 | },
35 | })
36 | }
37 | async function addSearchResultsStep() {
38 | // const searchResultsBotStep = getBotStep(
39 | // content.messageId,
40 | // content.params.searchResultsStepId!,
41 | // ) as SearchActionStepContent
42 | // log("Search results", { searchResultsBotStep })
43 | await addBotStep(content.messageId, {
44 | type: "MESSAGE_STEP",
45 | status: "COMPLETED",
46 | params: {
47 | messages: [
48 | {
49 | type: "WIDGET",
50 | params: {
51 | widget: "search",
52 | message: {
53 | query: content.params.context.query,
54 | searchResultsStepId: content.params.searchResultsStepId!,
55 | },
56 | },
57 | },
58 | ],
59 | },
60 | })
61 | }
62 | if (content.status !== "COMPLETED") {
63 | if (!content.params.searchResultsStepId) {
64 | invokeSearchAction()
65 | } else {
66 | addSearchResultsStep()
67 | updateBotStep(content.messageId, content.id, { status: "COMPLETED" })
68 | }
69 | }
70 | }, [content])
71 |
72 | const { messageId, id, status, params } = content
73 |
74 | return null
75 | // return (
76 | //
77 | //
SearchWorkflowStep
78 | //
Status: {status}
79 | //
MessageId: {messageId}
80 | //
id: {id}
81 | //
Params: {JSON.stringify(params)}
82 | //
83 | // )
84 | }
85 |
--------------------------------------------------------------------------------
/src/views/icons/file.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import {
3 | DocumentArrowUpIcon,
4 | DocumentMagnifyingGlassIcon,
5 | } from "@heroicons/react/24/outline"
6 |
7 | export function FileUploadIcon() {
8 | return (
9 |
10 |
11 |
12 | )
13 | }
14 |
15 | export function FileIndexIcon() {
16 | return (
17 |
18 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/src/views/icons/openai.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { config } from "../../../package.json"
3 |
4 | interface OpenAIIconProps {
5 | isLoading?: boolean
6 | }
7 |
8 | export function OpenAIIcon({ isLoading = false }: OpenAIIconProps) {
9 | return (
10 |
11 |
15 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/src/views/icons/style.css:
--------------------------------------------------------------------------------
1 | .icon-collection {
2 | height: 16px;
3 | width: 16px;
4 | }
5 |
6 | .icon-groups {
7 | height: 16px;
8 | width: 16px;
9 | }
--------------------------------------------------------------------------------
/src/views/icons/ui.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export function XCircleIcon() {
4 | return (
5 |
6 |
7 |
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/src/views/icons/zotero.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { StateName } from "../../typings/input"
3 | import "./style.css"
4 |
5 | interface IconProps {
6 | enlarged?: boolean
7 | }
8 |
9 | interface BaseIconProps extends IconProps {
10 | category: string
11 | type?: string | undefined
12 | }
13 |
14 | // All available icons: https://github.com/zotero/zotero/tree/f012a348af1143a7e033d697beae86df729080ab/chrome/skin/default/zotero
15 | function BaseIcon({ category, type, enlarged = false }: BaseIconProps) {
16 | // tag.png is no longer available: https://github.com/zotero/zotero/blob/main/chrome/skin/default/zotero/tag.svg
17 | const url =
18 | category === "tag"
19 | ? "chrome://zotero/skin/tag.svg"
20 | : type
21 | ? `chrome://zotero/skin/${category}-${type}${enlarged ? "@2x" : ""}.png`
22 | : `chrome://zotero/skin/${category}${enlarged ? "@2x" : ""}.png`
23 | return (
24 |
25 |
26 |
27 | )
28 | }
29 |
30 | interface ItemIconProps extends IconProps {
31 | type: _ZoteroTypes.Item.ItemType
32 | }
33 |
34 | export function ItemIcon({ type }: ItemIconProps) {
35 | const iconType = type
36 | .replace("pdf", "PDF")
37 | .replace("epub", "EPUB")
38 | .replace(/-(.)/g, (_: string, g1: string) => g1.toUpperCase())
39 | return (
40 |
44 | )
45 | }
46 |
47 | export function CollectionIcon(props: IconProps) {
48 | // return ;
49 | return
50 | }
51 |
52 | export function TagIcon(props: IconProps) {
53 | return
54 | }
55 |
56 | export function CreatorIcon(props: IconProps) {
57 | // return ;
58 | return
59 | }
60 |
61 | interface LocateItemProps extends IconProps {
62 | type: "external-viewer" | "internal-viewer" | "show-file" | "view-online"
63 | }
64 |
65 | export function LocateIcon(props: LocateItemProps) {
66 | return
67 | }
68 |
69 | export function SelectionIcon({
70 | name,
71 | id,
72 | type,
73 | }: {
74 | name: StateName
75 | id?: number
76 | type?: _ZoteroTypes.Item.ItemType | "collection" | "creator" | "tag" | "image"
77 | }) {
78 | switch (name) {
79 | case "items": {
80 | return
81 | }
82 | case "collections": {
83 | return
84 | }
85 | case "tags": {
86 | return
87 | }
88 | case "creators": {
89 | return
90 | }
91 | case "images":
92 | default: {
93 | return null
94 | }
95 | }
96 | }
97 |
98 | interface ZoteroIconProps {
99 | isLoading?: boolean
100 | }
101 |
102 | export function ZoteroIcon({ isLoading = false }: ZoteroIconProps) {
103 | return (
104 |
105 |
109 |
110 | )
111 | }
112 |
--------------------------------------------------------------------------------
/src/views/style.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | .dot-flashing {
6 | position: relative;
7 | width: 10px;
8 | height: 10px;
9 | border-radius: 5px;
10 | background-color: #9880ff;
11 | color: #9880ff;
12 | animation: dot-flashing 1s infinite linear alternate;
13 | animation-delay: 0.5s;
14 | }
15 | .dot-flashing::before,
16 | .dot-flashing::after {
17 | content: "";
18 | display: inline-block;
19 | position: absolute;
20 | top: 0;
21 | }
22 | .dot-flashing::before {
23 | left: -15px;
24 | width: 10px;
25 | height: 10px;
26 | border-radius: 5px;
27 | background-color: #9880ff;
28 | color: #9880ff;
29 | animation: dot-flashing 1s infinite alternate;
30 | animation-delay: 0s;
31 | }
32 | .dot-flashing::after {
33 | left: 15px;
34 | width: 10px;
35 | height: 10px;
36 | border-radius: 5px;
37 | background-color: #9880ff;
38 | color: #9880ff;
39 | animation: dot-flashing 1s infinite alternate;
40 | animation-delay: 1s;
41 | }
42 |
43 | @keyframes dot-flashing {
44 | 0% {
45 | background-color: #9880ff;
46 | }
47 | 50%,
48 | 100% {
49 | background-color: rgba(152, 128, 255, 0.2);
50 | }
51 | }
52 |
53 | .saturation-pulse {
54 | filter: saturate(0); /* Start as grayscale */
55 | animation: saturation-pulse 2s infinite; /* Apply the animation */
56 | }
57 |
58 | @keyframes saturation-pulse {
59 | 0% {
60 | filter: saturate(0); /* Grayscale */
61 | }
62 | 50% {
63 | filter: saturate(2); /* Over-saturated */
64 | }
65 | 100% {
66 | filter: saturate(0); /* Back to grayscale */
67 | }
68 | }
69 |
70 | .collapsible-panel-appear {
71 | display: none;
72 | opacity: 0;
73 | }
74 | .collapsible-panel-appear-active {
75 | opacity: 1;
76 | transition: opacity 200ms;
77 | }
78 | .collapsible-panel-appear-done {
79 | opacity: 1;
80 | }
81 | .collapsible-panel-enter {
82 | display: none;
83 | opacity: 0;
84 | display: grid;
85 | grid-template-rows: 0fr;
86 | }
87 |
88 | .collapsible-panel-enter-active {
89 | opacity: 1;
90 | display: grid;
91 | grid-template-rows: 1fr;
92 | transition:
93 | opacity 200ms,
94 | grid-template-rows 200ms;
95 | }
96 |
97 | .collapsible-panel-exit {
98 | opacity: 1;
99 | display: grid;
100 | grid-template-rows: 1fr;
101 | }
102 |
103 | .collapsible-panel-exit-active {
104 | opacity: 0;
105 | display: grid;
106 | grid-template-rows: 0fr;
107 | transition:
108 | opacity 200ms,
109 | grid-template-rows 200ms;
110 | }
111 |
112 | .collapsible-panel-exit-done {
113 | display: none;
114 | }
115 |
--------------------------------------------------------------------------------
/src/views/utils/markdown.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactNode } from "react"
2 | import { ItemButton } from "../components/buttons/ItemButton"
3 |
4 | export const customMarkdownRenderer = {
5 | link(href: string, content: ReactNode | string) {
6 | if (href.startsWith("aria://items/")) {
7 | const [type, id] = href.slice(13).split("/")
8 | return (
9 |
14 | )
15 | } else {
16 | return (
17 | Zotero.launchURL(href)}
21 | >
22 | {content}
23 |
24 | )
25 | }
26 | },
27 | }
28 |
--------------------------------------------------------------------------------
/src/workers/dbWorkers.ts:
--------------------------------------------------------------------------------
1 | import { MessageHelper } from "zotero-plugin-toolkit"
2 | import { db, ConversationDBSchema, MessageDBSchema, FileDBSchema } from "../db/db"
3 |
4 | // "handlers" define all the methods for message management in the database.
5 | export const handlers = {
6 | /**
7 | * Create or update a conversation.
8 | * If you want to *only* create if new, you can do `.add()` and handle conflicts,
9 | * but `.put()` replaces or inserts.
10 | */
11 | async upsertConversation(conversation: ConversationDBSchema) {
12 | await db.conversations.put(conversation)
13 | },
14 |
15 | /** Get a conversation by ID. */
16 | async getConversation(id: string) {
17 | const result = await db.conversations.get(id)
18 | return result
19 | },
20 |
21 | /** Remove a conversation. */
22 | async deleteConversation(id: string) {
23 | await db.conversations.delete(id)
24 | },
25 |
26 | /** Add or update a message. Use .put() so it overwrites if the same id exists. */
27 | async upsertMessage(message: MessageDBSchema) {
28 | await db.messages.put(message)
29 | },
30 |
31 | /** Add or update multiple messages. Use .bulkPut() to handle multiple messages at once. */
32 | async upsertMessages(messages: MessageDBSchema[]) {
33 | await db.messages.bulkPut(messages)
34 | },
35 |
36 | /** Get all messages in a conversation, sorted by timestamp. */
37 | async getMessages(conversationId: string) {
38 | const results = await db.messages
39 | .where("conversationId")
40 | .equals(conversationId)
41 | .sortBy("timestamp")
42 | return results
43 | },
44 |
45 | /** Delete a single message by ID. */
46 | async deleteMessage(id: string) {
47 | await db.messages.delete(id)
48 | },
49 |
50 | /** Delete multiple messages by their IDs. */
51 | async deleteMessages(ids: string[]) {
52 | await db.messages.bulkDelete(ids)
53 | },
54 |
55 | /** Clear all messages from the database. */
56 | async clearAllMessages() {
57 | await db.messages.clear()
58 | },
59 |
60 | /**
61 | * Clear an entire conversation’s messages,
62 | * but leave the conversation metadata if desired.
63 | */
64 | async clearMessagesForConversation(conversationId: string) {
65 | await db.messages
66 | .where("conversationId")
67 | .equals(conversationId)
68 | .delete()
69 | },
70 |
71 | /* Upsert file metadata */
72 | async upsertFile(file: FileDBSchema) {
73 | await db.files.put(file)
74 | },
75 |
76 | /* Get file metadata */
77 | async getFile(fileId: string) {
78 | const result = await db.files.get(fileId)
79 | return result
80 | },
81 |
82 | /* Delete file metadata */
83 | async deleteFile(fileId: string) {
84 | await db.files.delete(fileId)
85 | },
86 |
87 | /* Remove expired file metadata older than a threashold */
88 | async purgeFiles(maxAge: number) {
89 | const expirationDate = new Date()
90 | expirationDate.setDate(expirationDate.getDate() - maxAge)
91 | await db.files
92 | .where("timestamp")
93 | .below(expirationDate.toISOString())
94 | .delete()
95 | },
96 |
97 | /* Delete all file metadata */
98 | async clearAllFiles() {
99 | await db.files.clear()
100 | },
101 | }
102 |
103 |
104 |
105 | const messageServer = new MessageHelper({
106 | canBeDestroyed: true,
107 | dev: true,
108 | name: "dbWorker",
109 | target: self,
110 | handlers,
111 | })
112 | messageServer.start()
113 |
114 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ["./src/**/*.tsx"],
4 | theme: {
5 | extend: {
6 | backgroundImage: {
7 | 'gradient-170': 'linear-gradient(170deg, var(--tw-gradient-stops))'
8 | },
9 | colors: {
10 | 'tomato': 'RGB(204, 41, 54, 1)',
11 | },
12 | },
13 | },
14 | corePlugins: {
15 | preflight: false,
16 | },
17 | plugins: [],
18 | }
19 |
20 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "jsx": "react",
4 | "experimentalDecorators": true,
5 | "module": "commonjs",
6 | "target": "ES2016",
7 | "resolveJsonModule": true,
8 | "skipLibCheck": true,
9 | "strict": true,
10 | "esModuleInterop": true
11 | },
12 | "include": [
13 | "node_modules/zotero-types",
14 | "src",
15 | ],
16 | "exclude": [
17 | "builds",
18 | "addon"
19 | ]
20 | }
--------------------------------------------------------------------------------
/zotero-plugin.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "zotero-plugin-scaffold"
2 | import postCssPlugin from "esbuild-style-plugin"
3 | import tailwind from "tailwindcss"
4 | import autoprefixer from "autoprefixer"
5 | import pkg from "./package.json"
6 | import { auto } from "openai/_shims/registry.mjs"
7 |
8 | export default defineConfig({
9 | source: ["src", "addon"],
10 | dist: "build",
11 | name: pkg.config.addonName,
12 | id: pkg.config.addonID,
13 | namespace: pkg.config.addonRef,
14 | updateURL: `https://github.com/{{owner}}/{{repo}}/releases/download/release/${pkg.version.includes("-") ? "update-beta.json" : "update.json"
15 | }`,
16 | xpiDownloadLink:
17 | "https://github.com/{{owner}}/{{repo}}/releases/download/v{{version}}/{{xpiName}}.xpi",
18 |
19 | build: {
20 | assets: ["addon/**/*.*"],
21 | define: {
22 | ...pkg.config,
23 | author: pkg.author,
24 | description: pkg.description,
25 | homepage: pkg.homepage,
26 | buildVersion: pkg.version,
27 | buildTime: "{{buildTime}}",
28 | },
29 | esbuildOptions: [
30 | {
31 | entryPoints: ["src/index.ts"],
32 | define: {
33 | __env__: `"${process.env.NODE_ENV}"`,
34 | },
35 | plugins: [
36 | postCssPlugin({
37 | postcss: {
38 | plugins: [tailwind(), autoprefixer()]
39 | },
40 | }),
41 | ],
42 | bundle: true,
43 | target: "firefox115",
44 | outfile: `build/addon/chrome/content/scripts/${pkg.config.addonRef}.js`,
45 | },
46 | {
47 | entryPoints: ["src/workers/*.*"],
48 | define: {
49 | __env__: `"${process.env.NODE_ENV}"`,
50 | },
51 | outdir: "build/addon/chrome/content/scripts",
52 | bundle: true,
53 | target: ["firefox115"],
54 | },
55 | ],
56 | },
57 |
58 | // If you need to see a more detailed log, uncomment the following line:
59 | // logLevel: "trace",
60 | })
--------------------------------------------------------------------------------