chore: import upstream snapshot with attribution
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:28:29 +08:00
commit fed8b2eed7
1531 changed files with 1107494 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
docsgpt_url=<docsgpt_api_url>
chatwoot_url=<chatwoot_url>
docsgpt_key=<openai_api_key or other llm>
chatwoot_token=xxxxx
account_id=(optional) 1
assignee_id=(optional) 1
View File
+115
View File
@@ -0,0 +1,115 @@
import hashlib
import hmac
import os
import pprint
import dotenv
import requests
from flask import Flask, request
dotenv.load_dotenv()
docsgpt_url = os.getenv("docsgpt_url")
chatwoot_url = os.getenv("chatwoot_url")
docsgpt_key = os.getenv("docsgpt_key")
chatwoot_token = os.getenv("chatwoot_token")
chatwoot_webhook_secret = os.getenv("chatwoot_webhook_secret", "")
# account_id = os.getenv("account_id")
# assignee_id = os.getenv("assignee_id")
label_stop = "human-requested"
def send_to_bot(sender, message):
data = {
'sender': sender,
'question': message,
'api_key': docsgpt_key,
'embeddings_key': docsgpt_key,
'history': ''
}
headers = {"Content-Type": "application/json",
"Accept": "application/json"}
r = requests.post(f'{docsgpt_url}/api/answer',
json=data, headers=headers)
return r.json()['answer']
def send_to_chatwoot(account, conversation, message):
data = {
'content': message
}
url = f"{chatwoot_url}/api/v1/accounts/{account}/conversations/{conversation}/messages"
headers = {"Content-Type": "application/json",
"Accept": "application/json",
"api_access_token": f"{chatwoot_token}"}
r = requests.post(url,
json=data, headers=headers)
return r.json()
def is_valid_chatwoot_signature(raw_body: bytes, signature_header: str | None) -> bool:
"""Validate Chatwoot webhook signature using shared secret."""
if not chatwoot_webhook_secret or not signature_header:
return False
expected = hmac.new(
chatwoot_webhook_secret.encode("utf-8"), raw_body, hashlib.sha256
).hexdigest()
provided = signature_header.strip()
if provided.startswith("sha256="):
provided = provided.split("=", maxsplit=1)[1]
return hmac.compare_digest(provided, expected)
app = Flask(__name__)
@app.route('/docsgpt', methods=['POST'])
def docsgpt():
raw_body = request.get_data()
signature = request.headers.get("X-Chatwoot-Signature")
if not is_valid_chatwoot_signature(raw_body, signature):
return "Unauthorized", 401
data = request.get_json(silent=True)
if not isinstance(data, dict):
return "Invalid payload", 400
pp = pprint.PrettyPrinter(indent=4)
pp.pprint(data)
try:
message_type = data['message_type']
except KeyError:
return "Not a message"
message = data['content']
conversation = data['conversation']['id']
contact = data['sender']['id']
account = data['account']['id']
assignee = data['conversation']['meta']['assignee']['id']
print(account)
print(label_stop)
print(data['conversation']['labels'])
print(assignee)
if label_stop in data['conversation']['labels']:
return "Label stop"
# elif str(account) != str(account_id):
# return "Not the right account"
# elif str(assignee) != str(assignee_id):
# return "Not the right assignee"
if (message_type == "incoming"):
bot_response = send_to_bot(contact, message)
create_message = send_to_chatwoot(
account, conversation, bot_response)
else:
return "Not an incoming message"
return create_message
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
.parcel-cache
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "@parcel/config-default",
"resolvers": ["@parcel/resolver-glob","..."],
"transformers": {
"*.svg": ["...", "@parcel/transformer-svg-react", "@parcel/transformer-typescript-tsc"]
},
"validators": {
"*.{ts,tsx}": ["@parcel/validator-typescript"]
}
}
+177
View File
@@ -0,0 +1,177 @@
# DocsGPT react widget
This widget will allow you to embed a DocsGPT assistant in your React app.
## Installation
```bash
npm install docsgpt
```
## Usage
### React
```javascript
import { DocsGPTWidget } from "docsgpt-react";
const App = () => {
return <DocsGPTWidget />;
};
```
To link the widget to your api and your documents you can pass parameters to the <DocsGPTWidget /> component.
```javascript
import { DocsGPTWidget } from "docsgpt-react";
const App = () => {
return <DocsGPTWidget
apiHost="https://gptcloud.arc53.com"
apiKey=""
avatar = "https://d3dg1063dc54p9.cloudfront.net/cute-docsgpt.png"
title = "Get AI assistance"
description = "DocsGPT's AI Chatbot is here to help"
heroTitle = "Welcome to DocsGPT !"
heroDescription="This chatbot is built with DocsGPT and utilises GenAI,
please review important information using sources."
theme = "dark"
buttonIcon = "https://your-icon"
buttonBg = "#222327"
/>;
};
```
### Html
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DocsGPT Widget</title>
</head>
<body>
<div id="app"></div>
<!-- Include the widget script from dist/modern or dist/legacy -->
<script src="https://unpkg.com/docsgpt/dist/modern/main.js" type="module"></script>
<script type="module">
window.onload = function() {
renderDocsGPTWidget('app');
}
</script>
</body>
</html>
```
To link the widget to your api and your documents you can pass parameters to the **renderDocsGPTWidget('div id', { parameters })**.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DocsGPT Widget</title>
</head>
<body>
<div id="app"></div>
<!-- Include the widget script from dist/modern or dist/legacy -->
<script src="https://unpkg.com/docsgpt/dist/modern/main.js" type="module"></script>
<script type="module">
window.onload = function() {
renderDocsGPTWidget('app', {
apiHost: 'http://localhost:7001',
apiKey:"",
avatar: 'https://d3dg1063dc54p9.cloudfront.net/cute-docsgpt.png',
title: 'Get AI assistance',
description: "DocsGPT's AI Chatbot is here to help",
heroTitle: 'Welcome to DocsGPT!',
heroDescription: 'This chatbot is built with DocsGPT and utilises GenAI, please review important information using sources.',
theme:"dark",
buttonIcon:"https://your-icon.svg",
buttonBg:"#222327"
});
}
</script>
</body>
</html>
```
# SearchBar
The `SearchBar` component is an interactive search bar designed to provide search results based on **vector similarity search**. It also includes the capability to open the AI Chatbot, enabling users to query.
---
### Importing the Component
```tsx
import { SearchBar } from "docsgpt-react";
```
---
### Usage Example
```tsx
<SearchBar
apiKey="your-api-key"
apiHost="https://gptcloud.arc53.com"
theme="light"
placeholder="Search or Ask AI..."
width="300px"
/>
```
---
## HTML embedding for Search bar
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SearchBar Embedding</title>
<script src="https://unpkg.com/docsgpt/dist/modern/main.js"></script> <!-- The bundled JavaScript file -->
</head>
<body>
<!-- Element where the SearchBar will render -->
<div id="search-bar-container"></div>
<script>
// Render the SearchBar into the specified element
renderSearchBar('search-bar-container', {
apiKey: 'your-api-key-here',
apiHost: 'https://your-api-host.com',
theme: 'light',
placeholder: 'Search here...',
width: '300px'
});
</script>
</body>
</html>
```
### Props
| **Prop** | **Type** | **Default Value** | **Description** |
|-----------------|-----------|-------------------------------------|--------------------------------------------------------------------------------------------------|
| **`apiKey`** | `string` | `"74039c6d-bff7-44ce-ae55-2973cbf13837"` | Your API key generated from the app. Used for authenticating requests. |
| **`apiHost`** | `string` | `"https://gptcloud.arc53.com"` | The base URL of the server hosting the vector similarity search and chatbot services. |
| **`theme`** | `"dark" \| "light"` | `"dark"` | The theme of the search bar. Accepts `"dark"` or `"light"`. |
| **`placeholder`** | `string` | `"Search or Ask AI..."` | Placeholder text displayed in the search input field. |
| **`width`** | `string` | `"256px"` | Width of the search bar. Accepts any valid CSS width value (e.g., `"300px"`, `"100%"`, `"20rem"`). |
Feel free to reach out if you need help customizing or extending the `SearchBar`!
## Our github
[DocsGPT](https://github.com/arc53/DocsGPT)
You can find the source code in the extensions/react-widget folder.
View File
+72
View File
@@ -0,0 +1,72 @@
import js from '@eslint/js'
import tsParser from '@typescript-eslint/parser'
import tsPlugin from '@typescript-eslint/eslint-plugin'
import react from 'eslint-plugin-react'
import unusedImports from 'eslint-plugin-unused-imports'
import prettier from 'eslint-plugin-prettier'
import globals from 'globals'
export default [
{
ignores: [
'node_modules/',
'dist/',
'prettier.config.cjs',
'custom.d.ts',
'package-lock.json',
'package.json',
],
},
{
files: ['**/*.{js,jsx,ts,tsx}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parser: tsParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
globals: {
...globals.browser,
...globals.es2021,
...globals.node,
},
},
plugins: {
'@typescript-eslint': tsPlugin,
react,
'unused-imports': unusedImports,
prettier,
},
rules: {
...js.configs.recommended.rules,
...tsPlugin.configs.recommended.rules,
...react.configs.recommended.rules,
...prettier.configs.recommended.rules,
'react/prop-types': 'off',
'unused-imports/no-unused-imports': 'error',
'react/react-in-jsx-scope': 'off',
'no-undef': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{ varsIgnorePattern: '^_', argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-unused-expressions': 'warn',
'prettier/prettier': [
'error',
{
endOfLine: 'auto',
},
],
},
settings: {
react: {
version: 'detect',
},
},
},
]
File diff suppressed because it is too large Load Diff
+108
View File
@@ -0,0 +1,108 @@
{
"name": "docsgpt",
"version": "0.6.3",
"private": false,
"description": "DocsGPT 🦖 is an innovative open-source tool designed to simplify the retrieval of information from project documentation using advanced GPT models 🤖.",
"source": "./src/index.html",
"main": "dist/main.js",
"module": "dist/module.js",
"types": "dist/types.d.ts",
"files": [
"dist",
"package.json"
],
"targets": {
"modern": {
"engines": {
"browsers": "Chrome 80"
}
},
"legacy": {
"engines": {
"browsers": "> 0.5%, last 2 versions, not dead"
}
}
},
"@parcel/resolver-default": {
"packageExports": true
},
"resolution": {
"styled-components": "^5"
},
"scripts": {
"build": "parcel build src/browser.tsx --public-url ./",
"build:react": "parcel build src/index.ts",
"serve": "parcel serve -p 3000",
"dev": "parcel -p 3000",
"test": "jest",
"lint": "eslint ./src --ext .jsx,.js,.ts,.tsx",
"lint-fix": "eslint ./src --ext .jsx,.js,.ts,.tsx --fix",
"format": "prettier ./src --write",
"check": "tsc --noEmit",
"ci": "yarn build && yarn test && yarn lint && yarn check"
},
"dependencies": {
"@babel/plugin-transform-flow-strip-types": "^7.23.3",
"@bpmn-io/snarkdown": "^2.2.0",
"@parcel/resolver-glob": "^2.16.4",
"@parcel/transformer-svg-react": "^2.16.4",
"@parcel/transformer-typescript-tsc": "^2.16.4",
"@parcel/validator-typescript": "^2.16.4",
"@radix-ui/react-icons": "^1.3.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"dompurify": "^3.1.5",
"flow-bin": "^0.311.0",
"markdown-it": "^14.1.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"styled-components": "^6.1.8"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.24.0",
"@babel/preset-react": "^7.23.3",
"@eslint/js": "^9.39.4",
"@parcel/packager-ts": "^2.16.4",
"@parcel/transformer-typescript-types": "^2.16.4",
"@types/dompurify": "^3.0.5",
"@types/markdown-it": "^14.1.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0",
"babel-loader": "^10.1.1",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^17.5.0",
"parcel": "^2.16.4",
"prettier": "^3.8.1",
"process": "^0.11.10",
"svgo": "^4.0.1",
"typescript": "^6.0.3"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/arc53/DocsGPT.git"
},
"keywords": [
"docsgpt",
"chatbot",
"assistant",
"ai",
"chatdocs",
"widget"
],
"author": "Arc53",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/arc53/DocsGPT/issues"
},
"homepage": "https://github.com/arc53/DocsGPT#readme"
}
@@ -0,0 +1,8 @@
module.exports = {
trailingComma: 'all',
tabWidth: 2,
semi: true,
singleQuote: true,
printWidth: 80,
};
+90
View File
@@ -0,0 +1,90 @@
#!/bin/bash
set -e
# Create backup of original files
cp package.json package_original.json
cp package-lock.json package-lock_original.json
# Store the latest version after publishing
LATEST_VERSION=""
# Check if a specific version was provided
if [ "$1" ]; then
VERSION_UPDATE_TYPE="$1"
echo "Using custom version update: $VERSION_UPDATE_TYPE"
else
VERSION_UPDATE_TYPE="patch"
echo "No version specified, defaulting to patch update"
fi
publish_package() {
PACKAGE_NAME=$1
BUILD_COMMAND=$2
IS_REACT=$3
echo "Preparing to publish ${PACKAGE_NAME}..."
# Restore original package.json state before each publish
cp package_original.json package.json
cp package-lock_original.json package-lock.json
# Update package name in package.json
jq --arg name "$PACKAGE_NAME" '.name=$name' package.json > temp.json && mv temp.json package.json
# Handle targets based on package type
if [ "$IS_REACT" = "true" ]; then
echo "Removing targets for React library build..."
jq 'del(.targets)' package.json > temp.json && mv temp.json package.json
fi
# Clean dist directory
if [ -d "dist" ]; then
echo "Cleaning dist directory..."
rm -rf dist
fi
# Update version based on input parameter or default to patch
if [[ "$VERSION_UPDATE_TYPE" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
# If full version number is provided (e.g., 0.5.0)
LATEST_VERSION=$(npm version "$VERSION_UPDATE_TYPE" --no-git-tag-version)
else
# If update type is provided (patch, minor, major)
LATEST_VERSION=$(npm version "$VERSION_UPDATE_TYPE" --no-git-tag-version)
fi
echo "New version: ${LATEST_VERSION}"
# Build package
npm run "$BUILD_COMMAND"
# Publish package
npm publish
echo "Successfully published ${PACKAGE_NAME} version ${LATEST_VERSION}"
}
# First publish docsgpt (HTML bundle)
publish_package "docsgpt" "build" "false"
# Then publish docsgpt-react (React library)
publish_package "docsgpt-react" "build:react" "true"
# Restore original state but keep the updated version
cp package_original.json package.json
cp package-lock_original.json package-lock.json
# Update the version in the final package.json
jq --arg version "${LATEST_VERSION#v}" '.version=$version' package.json > temp.json && mv temp.json package.json
# Run npm install to update package-lock-only
npm install --package-lock-only
# Cleanup backup files
rm -f package_original.json
rm -f package-lock_original.json
rm -f temp.json
echo "---Process completed---"
echo "Final version in package.json: $(jq -r '.version' package.json)"
echo "Final version in package-lock.json: $(jq -r '.version' package-lock.json)"
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import { DocsGPTWidget } from './components/DocsGPTWidget';
import { SearchBar } from './components/SearchBar';
export const App = () => {
return (
<div>
<SearchBar />
<DocsGPTWidget />
</div>
);
};
+24
View File
@@ -0,0 +1,24 @@
//exports browser ready methods
import { createRoot } from 'react-dom/client';
import { DocsGPTWidget } from './components/DocsGPTWidget';
import { SearchBar } from './components/SearchBar';
import React from 'react';
if (typeof window !== 'undefined') {
const renderWidget = (elementId: string, props = {}) => {
const root = createRoot(document.getElementById(elementId) as HTMLElement);
root.render(<DocsGPTWidget {...props} />);
};
const renderSearchBar = (elementId: string, props = {}) => {
const root = createRoot(document.getElementById(elementId) as HTMLElement);
root.render(<SearchBar {...props} />);
};
(window as unknown as Record<string, unknown>).renderDocsGPTWidget =
renderWidget;
(window as unknown as Record<string, unknown>).renderSearchBar =
renderSearchBar;
}
export { DocsGPTWidget, SearchBar };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,611 @@
import React from 'react';
import styled, { ThemeProvider, createGlobalStyle } from 'styled-components';
import { WidgetCore } from './DocsGPTWidget';
import { SearchBarProps } from '@/types';
import { getSearchResults } from '../requests/searchAPI';
import { Result } from '@/types';
import { getOS, processMarkdownString } from '../utils/helper';
import DOMPurify from 'dompurify';
import {
CodeIcon,
TextAlignLeftIcon,
HeadingIcon,
ReaderIcon,
ListBulletIcon,
QuoteIcon,
} from '@radix-ui/react-icons';
const themes = {
dark: {
name: 'dark',
bg: '#202124',
text: '#EDEDED',
primary: {
text: '#FAFAFA',
bg: '#111111',
},
secondary: {
text: '#A1A1AA',
bg: '#38383b',
},
},
light: {
name: 'light',
bg: '#EAEAEA',
text: '#171717',
primary: {
text: '#222327',
bg: '#fff',
},
secondary: {
text: '#A1A1AA',
bg: '#F6F6F6',
},
},
};
const GlobalStyle = createGlobalStyle`
.highlight {
color: ${(props) => (props.theme.name === 'dark' ? '#4B9EFF' : '#0066CC')};
font-weight: 500;
}
`;
const loadGeistFont = () => {
const link = document.createElement('link');
link.href =
'https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap';
link.rel = 'stylesheet';
document.head.appendChild(link);
};
const Main = styled.div`
all: initial;
font-family: 'Geist', sans-serif;
`;
const SearchButton = styled.button<{ $inputWidth: string }>`
padding: 6px 6px;
font-family: inherit;
width: ${({ $inputWidth }) => $inputWidth};
border-radius: 8px;
display: inline;
color: ${(props) => props.theme.secondary.text};
outline: none;
border: none;
background-color: ${(props) => props.theme.secondary.bg};
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
transition: background-color 128ms linear;
text-align: left;
cursor: pointer;
`;
const Container = styled.div`
position: relative;
display: inline-block;
`;
const SearchOverlay = styled.div`
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #0000001a;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 99;
`;
const SearchResults = styled.div`
position: fixed;
display: flex;
flex-direction: column;
background-color: ${(props) =>
props.theme.name === 'dark'
? 'rgba(0, 0, 0, 0.15)'
: 'rgba(255, 255, 255, 0.4)'};
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 15px;
padding: 8px 0px 8px 0px;
width: 792px;
max-width: 90vw;
height: 396px;
z-index: 100;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
color: ${(props) => props.theme.primary.text};
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
backdrop-filter: blur(82px);
-webkit-backdrop-filter: blur(82px);
border-radius: 10px;
box-sizing: border-box;
@media only screen and (max-width: 768px) {
height: 80vh;
width: 90vw;
}
`;
const SearchResultsScroll = styled.div`
flex: 1;
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: #383838 transparent;
padding: 0 16px;
`;
const IconTitleWrapper = styled.div`
display: flex;
align-items: center;
gap: 8px;
.element-icon {
margin: 4px;
}
`;
const Title = styled.h3`
font-size: 15px;
font-weight: 400;
color: ${(props) => props.theme.primary.text};
margin: 0;
overflow-wrap: break-word;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
`;
const ContentWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
`;
const ResultWrapper = styled.div`
display: flex;
align-items: flex-start;
width: 100%;
box-sizing: border-box;
padding: 8px 16px;
cursor: pointer;
background-color: transparent;
font-family: 'Geist', sans-serif;
border-radius: 8px;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
`;
const Content = styled.div`
display: flex;
margin-left: 8px;
flex-direction: column;
gap: 8px;
padding: 4px 0px 0px 12px;
font-size: 15px;
color: ${(props) => props.theme.primary.text};
line-height: 1.6;
border-left: 2px solid ${(props) => props.theme.primary.text}CC;
overflow: hidden;
`;
const ContentSegment = styled.div`
display: flex;
align-items: flex-start;
gap: 8px;
padding-right: 16px;
overflow-wrap: break-word;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
`;
const Toolkit = styled.kbd`
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
background-color: ${(props) => props.theme.primary.bg};
color: ${(props) => props.theme.secondary.text};
font-weight: 600;
font-size: 10px;
padding: 3px 6px;
border: 1px solid ${(props) => props.theme.secondary.text};
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
pointer-events: none;
`;
const Loader = styled.div`
margin: 2rem auto;
border: 4px solid
${(props) =>
props.theme.name === 'dark'
? 'rgba(255, 255, 255, 0.2)'
: 'rgba(0, 0, 0, 0.1)'};
border-top: 4px solid
${(props) =>
props.theme.name === 'dark' ? '#FFFFFF' : props.theme.primary.bg};
border-radius: 50%;
width: 12px;
height: 12px;
animation: spin 1s linear infinite;
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
`;
const NoResults = styled.div`
margin-top: 2rem;
text-align: center;
font-size: 14px;
color: ${(props) => (props.theme.name === 'dark' ? '#E0E0E0' : '#505050')};
font-weight: 500;
`;
const AskAIButton = styled.button`
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
width: calc(100% - 32px);
margin: 0 16px 16px 16px;
box-sizing: border-box;
height: 50px;
padding: 8px 24px;
border: none;
border-radius: 8px;
color: ${(props) => props.theme.text};
cursor: pointer;
font-size: 16px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
background-color: ${(props) =>
props.theme.name === 'dark'
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.03)'};
&:hover {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
background-color: ${(props) =>
props.theme.name === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.06)'};
}
`;
const SearchHeader = styled.div`
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid
${(props) =>
props.theme.name === 'dark' ? '#FFFFFF24' : 'rgba(0, 0, 0, 0.14)'};
`;
const TextField = styled.input`
width: calc(100% - 32px);
margin: 0 16px;
padding: 12px 16px;
border: none;
background-color: transparent;
color: ${(props) => props.theme.text};
font-size: 20px;
font-weight: 400;
outline: none;
&:focus {
border-color: none;
}
&::placeholder {
color: ${(props) =>
props.theme.name === 'dark'
? 'rgba(255, 255, 255, 0.6)'
: 'rgba(0, 0, 0, 0.5)'} !important;
opacity: 100%; /* Force opacity to ensure placeholder is visible */
font-weight: 500;
}
`;
const EscapeInstruction = styled.kbd`
display: flex;
align-items: center;
justify-content: center;
margin: 12px 16px 0;
padding: 4px 8px;
border-radius: 4px;
background-color: transparent;
border: 1px solid
${(props) =>
props.theme.name === 'dark'
? 'rgba(237, 237, 237, 0.6)'
: 'rgba(23, 23, 23, 0.6)'};
color: ${(props) => (props.theme.name === 'dark' ? '#EDEDED' : '#171717')};
font-size: 12px;
font-family: 'Geist', sans-serif;
white-space: nowrap;
cursor: pointer;
width: fit-content;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
`;
export const SearchBar = ({
apiKey = '74039c6d-bff7-44ce-ae55-2973cbf13837',
apiHost = 'https://gptcloud.arc53.com',
theme = 'dark',
placeholder = 'Search or Ask AI...',
width = '256px',
buttonText = 'Search here',
}: SearchBarProps) => {
const [input, setInput] = React.useState<string>('');
const [loading, setLoading] = React.useState<boolean>(false);
const [isWidgetOpen, setIsWidgetOpen] = React.useState<boolean>(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const containerRef = React.useRef<HTMLInputElement>(null);
const [isResultVisible, setIsResultVisible] = React.useState<boolean>(false);
const [results, setResults] = React.useState<Result[]>([]);
const debounceTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const abortControllerRef = React.useRef<AbortController | null>(null);
const browserOS = getOS();
const isTouch = 'ontouchstart' in window;
const getKeyboardInstruction = () => {
if (isResultVisible) return 'Enter';
return browserOS === 'mac' ? '⌘ + K' : 'Ctrl + K';
};
React.useEffect(() => {
loadGeistFont();
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsResultVisible(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (
((browserOS === 'win' || browserOS === 'linux') &&
event.ctrlKey &&
event.key === 'k') ||
(browserOS === 'mac' && event.metaKey && event.key === 'k')
) {
event.preventDefault();
inputRef.current?.focus();
setIsResultVisible(true);
} else if (event.key === 'Escape') {
setIsResultVisible(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
React.useEffect(() => {
if (!input) {
setResults([]);
setLoading(false);
return;
}
setLoading(true);
if (debounceTimeout.current) {
clearTimeout(debounceTimeout.current);
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
debounceTimeout.current = setTimeout(() => {
getSearchResults(input, apiKey, apiHost, abortController.signal)
.then((data) => setResults(data))
.catch((err) => !abortController.signal.aborted && console.log(err))
.finally(() => setLoading(false));
}, 500);
return () => {
abortController.abort();
clearTimeout(debounceTimeout.current ?? undefined);
};
}, [input]);
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
openWidget();
}
};
const openWidget = () => {
setIsWidgetOpen(true);
setIsResultVisible(false);
};
const handleClose = () => {
setIsWidgetOpen(false);
setIsResultVisible(true);
};
return (
<ThemeProvider theme={{ ...themes[theme] }}>
<Main>
<GlobalStyle />
<Container ref={containerRef}>
<SearchButton
onClick={() => setIsResultVisible(true)}
$inputWidth={width}
>
{buttonText}
</SearchButton>
{isResultVisible && (
<>
<SearchOverlay onClick={() => setIsResultVisible(false)} />
<SearchResults>
<SearchHeader>
<TextField
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => handleKeyDown(e)}
placeholder={placeholder}
autoFocus
/>
<EscapeInstruction onClick={() => setIsResultVisible(false)}>
Esc
</EscapeInstruction>
</SearchHeader>
<AskAIButton onClick={openWidget}>
<img
src="https://d3dg1063dc54p9.cloudfront.net/cute-docsgpt.png"
alt="DocsGPT"
width={24}
height={24}
/>
<span>Ask the AI</span>
</AskAIButton>
<SearchResultsScroll>
{!loading ? (
results.length > 0 ? (
results.map((res, key) => {
const containsSource = res.source !== 'local';
const processedResults = processMarkdownString(
res.text,
input,
);
if (processedResults)
return (
<ResultWrapper
key={key}
onClick={() => {
if (!containsSource) return;
window.open(
res.source,
'_blank',
'noopener, noreferrer',
);
}}
>
<div style={{ flex: 1 }}>
<ContentWrapper>
<IconTitleWrapper>
<ReaderIcon className="title-icon" />
<Title>{res.title}</Title>
</IconTitleWrapper>
<Content>
{processedResults.map((element, index) => (
<ContentSegment key={index}>
<IconTitleWrapper>
{element.tag === 'code' && (
<CodeIcon className="element-icon" />
)}
{(element.tag === 'bulletList' ||
element.tag === 'numberedList') && (
<ListBulletIcon className="element-icon" />
)}
{element.tag === 'text' && (
<TextAlignLeftIcon className="element-icon" />
)}
{element.tag === 'heading' && (
<HeadingIcon className="element-icon" />
)}
{element.tag === 'blockquote' && (
<QuoteIcon className="element-icon" />
)}
</IconTitleWrapper>
<div
style={{ flex: 1 }}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(
element.content,
),
}}
/>
</ContentSegment>
))}
</Content>
</ContentWrapper>
</div>
</ResultWrapper>
);
return null;
})
) : (
<NoResults>No results found</NoResults>
)
) : (
<Loader />
)}
</SearchResultsScroll>
</SearchResults>
</>
)}
{isTouch ? (
<Toolkit
onClick={() => {
setIsWidgetOpen(true);
}}
title={'Tap to Ask the AI'}
>
Tap
</Toolkit>
) : (
<Toolkit
title={
getKeyboardInstruction() === 'Enter'
? 'Press Enter to Ask AI'
: ''
}
>
{getKeyboardInstruction()}
</Toolkit>
)}
</Container>
<WidgetCore
theme={theme}
apiHost={apiHost}
apiKey={apiKey}
prefilledQuery={input}
isOpen={isWidgetOpen}
handleClose={handleClose}
size={'large'}
/>
</Main>
</ThemeProvider>
);
};
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DocsGPT Widget</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="main.tsx"></script>
<!-- <script type="module">
window.onload = function() {
renderDocsGPTWidget('app');
renderSearchBar('app')
}
</script> -->
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
//exports methods for React
export { SearchBar } from './components/SearchBar';
export { DocsGPTWidget } from './components/DocsGPTWidget';
+7
View File
@@ -0,0 +1,7 @@
//development
import { createRoot } from 'react-dom/client';
import { App } from './App';
import React from 'react';
const container = document.getElementById('app') as HTMLElement;
const root = createRoot(container);
root.render(<App />);
@@ -0,0 +1,38 @@
import { Result } from '@/types';
async function getSearchResults(
question: string,
apiKey: string,
apiHost: string,
signal: AbortSignal,
): Promise<Result[]> {
const payload = {
question,
api_key: apiKey,
};
try {
const response = await fetch(`${apiHost}/api/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
signal: signal,
});
if (!response.ok) {
throw new Error(`Error: ${response.status}`);
}
const data: Result[] = await response.json();
return data;
} catch (error) {
if (!(error instanceof DOMException && error.name == 'AbortError')) {
console.error('Failed to fetch documents:', error);
}
throw error;
}
}
export { getSearchResults };
@@ -0,0 +1,116 @@
interface HistoryItem {
prompt: string;
response?: string;
}
interface FetchAnswerStreamingProps {
question?: string;
apiKey?: string;
selectedDocs?: string;
history?: HistoryItem[];
conversationId?: string | null;
apiHost?: string;
onEvent?: (event: MessageEvent) => void;
}
export interface FeedbackPayload {
question?: string;
answer?: string;
feedback: string | null;
apikey?: string;
conversation_id: string;
question_index: number;
}
export function fetchAnswerStreaming({
question = '',
apiKey = '',
history = [],
conversationId = null,
apiHost = '',
onEvent = () => {
console.log('Event triggered, but no handler provided.');
},
}: FetchAnswerStreamingProps): Promise<void> {
return new Promise<void>((resolve, reject) => {
const body = {
question: question,
history: JSON.stringify(history),
conversation_id: conversationId,
model: 'default',
api_key: apiKey,
};
fetch(apiHost + '/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
.then((response) => {
if (!response.body) throw Error('No response body');
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let counter = 0; // eslint-disable-line @typescript-eslint/no-unused-vars
const processStream = ({
done,
value,
}: ReadableStreamReadResult<Uint8Array>) => {
if (done) {
resolve();
return;
}
counter += 1;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (let line of lines) {
if (line.trim() == '') {
continue;
}
if (line.startsWith('data:')) {
line = line.substring(5);
}
const messageEvent = new MessageEvent('message', {
data: line,
});
onEvent(messageEvent); // handle each message
}
reader.read().then(processStream).catch(reject);
};
reader.read().then(processStream).catch(reject);
})
.catch((error) => {
console.error('Connection failed:', error);
reject(error);
});
});
}
export const sendFeedback = (
payload: FeedbackPayload,
apiHost: string,
): Promise<Response> => {
return fetch(`${apiHost}/api/feedback`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
question: payload.question,
answer: payload.answer,
feedback: payload.feedback,
api_key: payload.apikey,
conversation_id: payload.conversation_id,
question_index: payload.question_index,
}),
});
};
@@ -0,0 +1,94 @@
import 'styled-components';
declare module 'styled-components' {
export interface DefaultTheme {
bg: string;
text: string;
primary: {
text: string;
bg: string;
};
secondary: {
text: string;
bg: string;
};
/** Present only in SearchBar theme */
name?: string;
/** Present only in DocsGPTWidget theme (always provided when these styled components render) */
dimensions?: {
size: string;
width: string;
height: string;
maxWidth?: string;
maxHeight?: string;
};
}
}
export type MESSAGE_TYPE = 'QUESTION' | 'ANSWER' | 'ERROR';
export type Status = 'idle' | 'loading' | 'failed';
export type FEEDBACK = 'LIKE' | 'DISLIKE';
export type THEME = 'light' | 'dark';
export interface Query {
prompt: string;
response?: string;
feedback?: FEEDBACK;
error?: string;
sources?: { title: string; text: string; source: string }[];
conversationId?: string | null;
title?: string | null;
}
export interface WidgetProps {
apiHost?: string;
apiKey?: string;
avatar?: string;
title?: string;
description?: string;
heroTitle?: string;
heroDescription?: string;
size?:
| 'small'
| 'medium'
| 'large'
| {
custom: {
width: string;
height: string;
maxWidth?: string;
maxHeight?: string;
};
};
theme?: THEME;
buttonIcon?: string;
buttonText?: string;
buttonBg?: string;
collectFeedback?: boolean;
showSources?: boolean;
defaultOpen?: boolean;
}
export interface WidgetCoreProps extends WidgetProps {
widgetRef?: React.RefObject<HTMLDivElement> | null;
handleClose?: React.MouseEventHandler | undefined;
isOpen: boolean;
prefilledQuery?: string;
}
export interface SearchBarProps {
apiHost?: string;
apiKey?: string;
theme?: THEME;
placeholder?: string;
width?: string;
buttonText?: string;
}
export interface Result {
text: string;
title: string;
source: string;
}
+167
View File
@@ -0,0 +1,167 @@
export const getOS = () => {
const platform = window.navigator.platform;
const userAgent = window.navigator.userAgent || window.navigator.vendor;
if (/Mac/i.test(platform)) {
return 'mac';
}
if (/Win/i.test(platform)) {
return 'win';
}
if (/Linux/i.test(platform) && !/Android/i.test(userAgent)) {
return 'linux';
}
if (/Android/i.test(userAgent)) {
return 'android';
}
if (/iPhone|iPad|iPod/i.test(userAgent)) {
return 'ios';
}
return 'other';
};
interface ParsedElement {
content: string;
tag: string;
}
export const processMarkdownString = (
markdown: string,
keyword?: string,
): ParsedElement[] => {
const lines = markdown.trim().split('\n');
const keywordLower = keyword?.toLowerCase();
const escapeRegExp = (str: string) =>
str.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
const escapedKeyword = keyword ? escapeRegExp(keyword) : '';
const keywordRegex = keyword ? new RegExp(`(${escapedKeyword})`, 'gi') : null;
let isInCodeBlock = false;
let codeBlockContent: string[] = [];
let matchingLines: ParsedElement[] = [];
let firstLine: ParsedElement | null = null;
for (let i = 0; i < lines.length; i++) {
const trimmedLine = lines[i].trim();
if (!trimmedLine) continue;
if (trimmedLine.startsWith('```')) {
if (!isInCodeBlock) {
isInCodeBlock = true;
codeBlockContent = [];
} else {
isInCodeBlock = false;
const codeContent = codeBlockContent.join('\n');
const parsedElement: ParsedElement = {
content: codeContent,
tag: 'code',
};
if (!firstLine) {
firstLine = parsedElement;
}
if (keywordLower && codeContent.toLowerCase().includes(keywordLower)) {
parsedElement.content = parsedElement.content.replace(
keywordRegex!,
'<span class="highlight">$1</span>',
);
matchingLines.push(parsedElement);
}
}
continue;
}
if (isInCodeBlock) {
codeBlockContent.push(trimmedLine);
continue;
}
let parsedElement: ParsedElement | null = null;
const headingMatch = trimmedLine.match(/^(#{1,6})\s+(.+)$/);
const bulletMatch = trimmedLine.match(/^[-*]\s+(.+)$/);
const numberedMatch = trimmedLine.match(/^\d+\.\s+(.+)$/);
const blockquoteMatch = trimmedLine.match(/^>+\s*(.+)$/);
let content = trimmedLine;
if (headingMatch) {
content = headingMatch[2];
parsedElement = {
content: content,
tag: 'heading',
};
} else if (bulletMatch) {
content = bulletMatch[1];
parsedElement = {
content: content,
tag: 'bulletList',
};
} else if (numberedMatch) {
content = numberedMatch[1];
parsedElement = {
content: content,
tag: 'numberedList',
};
} else if (blockquoteMatch) {
content = blockquoteMatch[1];
parsedElement = {
content: content,
tag: 'blockquote',
};
} else {
parsedElement = {
content: content,
tag: 'text',
};
}
if (!firstLine) {
firstLine = parsedElement;
}
if (
keywordLower &&
parsedElement.content.toLowerCase().includes(keywordLower)
) {
parsedElement.content = parsedElement.content.replace(
keywordRegex!,
'<span class="highlight">$1</span>',
);
matchingLines.push(parsedElement);
}
}
if (isInCodeBlock && codeBlockContent.length > 0) {
const codeContent = codeBlockContent.join('\n');
const parsedElement: ParsedElement = {
content: codeContent,
tag: 'code',
};
if (!firstLine) {
firstLine = parsedElement;
}
if (keywordLower && codeContent.toLowerCase().includes(keywordLower)) {
parsedElement.content = parsedElement.content.replace(
keywordRegex!,
'<span class="highlight">$1</span>',
);
matchingLines.push(parsedElement);
}
}
if (keywordLower && matchingLines.length > 0) {
return matchingLines;
}
return firstLine ? [firstLine] : [];
};
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
},
/* The "typeRoots" configuration specifies the locations where
TypeScript looks for type definitions (.d.ts files) to
include in the compilation process.*/
"typeRoots": ["./dist/index.d.ts", "node_modules/@types"]
},
/* include /index.ts*/
"include": ["src/index.ts","custom.d.ts"],
"exclude": ["node_modules"],
}