forked from innovacion/Mayacontigo
ic
This commit is contained in:
0
packages/azure-ada/README.md
Normal file
0
packages/azure-ada/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
from .blocking import Ada
|
||||
from .nonblocking import AsyncAda
|
||||
|
||||
__all__ = ["Ada", "AsyncAda"]
|
||||
51
packages/azure-ada/banortegpt/embedding/azure_ada/base.py
Normal file
51
packages/azure-ada/banortegpt/embedding/azure_ada/base.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class BaseAda:
|
||||
def __init__(
|
||||
self, model: str | None = None, *, endpoint: str, key: str, version: str
|
||||
) -> None:
|
||||
self.model = model
|
||||
|
||||
class Config(Protocol):
|
||||
embedding_model: str
|
||||
azure_endpoint: str
|
||||
openai_api_key: str
|
||||
openai_api_version: str
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, c: Config):
|
||||
return cls(
|
||||
model=c.embedding_model,
|
||||
endpoint=c.azure_endpoint,
|
||||
key=c.openai_api_key,
|
||||
version=c.openai_api_version,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_vault(
|
||||
cls,
|
||||
vault: str,
|
||||
*,
|
||||
model: str | None = None,
|
||||
url: str | None = None,
|
||||
token: str | None = None,
|
||||
mount_point: str = "secret",
|
||||
):
|
||||
from hvac import Client
|
||||
|
||||
client = Client(url=url or "https://vault.ia-innovacion.work", token=token)
|
||||
|
||||
if not client.is_authenticated():
|
||||
raise Exception("Vault authentication failed")
|
||||
|
||||
secret_map = client.secrets.kv.v2.read_secret_version(
|
||||
path=vault, mount_point=mount_point
|
||||
)["data"]["data"]
|
||||
|
||||
return cls(
|
||||
model=model,
|
||||
endpoint=secret_map["azure_endpoint"],
|
||||
key=secret_map["openai_api_key"],
|
||||
version=secret_map["openai_api_version"],
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
from langfuse.openai import AzureOpenAI
|
||||
from openai.types.embedding import Embedding
|
||||
|
||||
from .base import BaseAda
|
||||
|
||||
|
||||
class Ada(BaseAda):
|
||||
def __init__(
|
||||
self, model: str | None = None, *, endpoint: str, key: str, version: str
|
||||
) -> None:
|
||||
super().__init__(model, endpoint=endpoint, key=key, version=version)
|
||||
self.client = AzureOpenAI(
|
||||
azure_endpoint=endpoint, api_key=key, api_version=version
|
||||
)
|
||||
|
||||
def embed(
|
||||
self, input: str | list[str], *, model: str | None = None
|
||||
) -> list[float] | list[list[float]]:
|
||||
if isinstance(input, str):
|
||||
return self.embed_query(input, model)
|
||||
else:
|
||||
return self.batch_embed(input, model)
|
||||
|
||||
def batch_embed(
|
||||
self, texts: list[str], model: str | None = None
|
||||
) -> list[list[float]]:
|
||||
if model is None:
|
||||
if self.model is None:
|
||||
raise ValueError("No embedding model set")
|
||||
model = self.model
|
||||
|
||||
batches = [texts[i : i + 2048] for i in range(0, len(texts), 2048)]
|
||||
results = [
|
||||
(self.client.embeddings.create(input=batch, model=model)).data
|
||||
for batch in batches
|
||||
]
|
||||
flattened_results: list[Embedding] = sum(results, [])
|
||||
return [result.embedding for result in flattened_results]
|
||||
|
||||
def embed_query(self, text: str, model: str | None = None) -> list[float]:
|
||||
if model is None:
|
||||
if self.model is None:
|
||||
raise ValueError("No embedding model set")
|
||||
model = self.model
|
||||
|
||||
response = self.client.embeddings.create(input=text, model=model)
|
||||
return response.data[0].embedding
|
||||
@@ -0,0 +1,47 @@
|
||||
from langfuse.openai import AsyncAzureOpenAI
|
||||
from openai.types.embedding import Embedding
|
||||
|
||||
from .base import BaseAda
|
||||
|
||||
|
||||
class AsyncAda(BaseAda):
|
||||
def __init__(
|
||||
self, model: str | None = None, *, endpoint: str, key: str, version: str
|
||||
) -> None:
|
||||
super().__init__(model, endpoint=endpoint, key=key, version=version)
|
||||
self.client = AsyncAzureOpenAI(
|
||||
azure_endpoint=endpoint, api_key=key, api_version=version
|
||||
)
|
||||
|
||||
async def embed(
|
||||
self, input: str | list[str], *, model: str | None = None
|
||||
) -> list[float] | list[list[float]]:
|
||||
if isinstance(input, str):
|
||||
return await self.embed_query(input, model)
|
||||
else:
|
||||
return await self.batch_embed(input, model)
|
||||
|
||||
async def batch_embed(
|
||||
self, texts: list[str], model: str | None = None
|
||||
) -> list[list[float]]:
|
||||
if model is None:
|
||||
if self.model is None:
|
||||
raise ValueError("No embedding model set")
|
||||
model = self.model
|
||||
|
||||
batches = [texts[i : i + 2048] for i in range(0, len(texts), 2048)]
|
||||
results = [
|
||||
(await self.client.embeddings.create(input=batch, model=model)).data
|
||||
for batch in batches
|
||||
]
|
||||
flattened_results: list[Embedding] = sum(results, [])
|
||||
return [result.embedding for result in flattened_results]
|
||||
|
||||
async def embed_query(self, text: str, model: str | None = None) -> list[float]:
|
||||
if model is None:
|
||||
if self.model is None:
|
||||
raise ValueError("No embedding model set")
|
||||
model = self.model
|
||||
|
||||
response = await self.client.embeddings.create(input=text, model=model)
|
||||
return response.data[0].embedding
|
||||
15
packages/azure-ada/pyproject.toml
Normal file
15
packages/azure-ada/pyproject.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[project]
|
||||
name = "azure-ada"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
authors = [{ name = "ajac-zero", email = "ajcardoza2000@gmail.com" }]
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["hvac","openai>=1.72.0"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["banortegpt"]
|
||||
0
packages/azure-gpt/README.md
Normal file
0
packages/azure-gpt/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
from typing import Protocol, cast
|
||||
|
||||
from openai import AsyncAzureOpenAI
|
||||
from openai.types.chat import ChatCompletion
|
||||
|
||||
|
||||
class AsyncGPT:
|
||||
def __init__(self, azure_endpoint: str, api_key: str, api_version: str) -> None:
|
||||
self.client = AsyncAzureOpenAI(
|
||||
azure_endpoint=azure_endpoint, api_key=api_key, api_version=api_version
|
||||
)
|
||||
|
||||
async def generate(self, messages, model, **kwargs):
|
||||
response = await self.client.chat.completions.create(
|
||||
messages=messages, model=model, **kwargs
|
||||
)
|
||||
|
||||
response = cast(ChatCompletion, response)
|
||||
|
||||
return response.choices[0].message
|
||||
|
||||
async def stream(self, messages, model, **kwargs):
|
||||
response = await self.client.chat.completions.create(
|
||||
messages=messages, model=model, stream=True, **kwargs
|
||||
)
|
||||
|
||||
async for chunk in response:
|
||||
if choices := chunk.choices:
|
||||
yield choices[0].delta
|
||||
|
||||
@staticmethod
|
||||
def build_tool_call(tool_id: str, tool_name: str, tool_buffer: str):
|
||||
tool_call = {
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tool_id,
|
||||
"function": {
|
||||
"name": tool_name,
|
||||
"arguments": tool_buffer,
|
||||
},
|
||||
"type": "function",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return tool_call
|
||||
|
||||
@staticmethod
|
||||
def build_tool_call_id(tool_id: str):
|
||||
return {"tool_call_id": tool_id}
|
||||
|
||||
class Config(Protocol):
|
||||
azure_endpoint: str
|
||||
openai_api_key: str
|
||||
openai_api_version: str
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: Config):
|
||||
return cls(
|
||||
config.azure_endpoint, config.openai_api_key, config.openai_api_version
|
||||
)
|
||||
19
packages/azure-gpt/pyproject.toml
Normal file
19
packages/azure-gpt/pyproject.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[project]
|
||||
name = "azure-gpt"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
authors = [{ name = "ajac-zero", email = "ajcardoza2000@gmail.com" }]
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [ "openai>=1.72.0", "pydantic>=2.10.4"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["banortegpt"]
|
||||
|
||||
[tool.pyright]
|
||||
venvPath = "../../."
|
||||
venv = ".venv"
|
||||
0
packages/azure-storage/README.md
Normal file
0
packages/azure-storage/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Protocol
|
||||
|
||||
from azure.storage.blob import BlobSasPermissions, generate_blob_sas
|
||||
from azure.storage.blob.aio import BlobServiceClient
|
||||
|
||||
|
||||
class AzureStorage:
|
||||
def __init__(self, connection_string: str | None):
|
||||
if connection_string:
|
||||
self.client = BlobServiceClient.from_connection_string(connection_string)
|
||||
|
||||
def _generate_sas_token(self, filename: str, bucket: str, minute_duration: int):
|
||||
expiry_time = datetime.now(UTC) + timedelta(minutes=minute_duration)
|
||||
|
||||
token = generate_blob_sas(
|
||||
account_name=self.client.account_name, # type: ignore
|
||||
container_name=bucket,
|
||||
blob_name=filename,
|
||||
account_key=self.client.credential.account_key,
|
||||
permission=BlobSasPermissions(read=True),
|
||||
expiry=expiry_time,
|
||||
)
|
||||
|
||||
return token
|
||||
|
||||
async def get_file_url(
|
||||
self, filename: str, bucket: str, minute_duration: int, image: bool
|
||||
) -> str | None:
|
||||
if not hasattr(self, "client"):
|
||||
return None
|
||||
|
||||
blob_client = self.client.get_blob_client(container=bucket, blob=filename)
|
||||
|
||||
exists = await blob_client.exists()
|
||||
|
||||
if exists:
|
||||
sas_token = self._generate_sas_token(filename, bucket, minute_duration)
|
||||
return f"{blob_client.url}?{sas_token}"
|
||||
else:
|
||||
return None
|
||||
|
||||
async def get_blob_bytes(self, bucket: str, filename: str):
|
||||
if not hasattr(self, "client"):
|
||||
raise ValueError("No connection string provided to AzureStorage object.")
|
||||
blob_client = self.client.get_blob_client(container=bucket, blob=filename)
|
||||
return (await blob_client.download_blob()).readall()
|
||||
|
||||
class Config(Protocol):
|
||||
azure_blob_connection_string: str
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, c: Config):
|
||||
return cls(c.azure_blob_connection_string)
|
||||
15
packages/azure-storage/pyproject.toml
Normal file
15
packages/azure-storage/pyproject.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[project]
|
||||
name = "azure-storage"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
authors = [{ name = "ajac-zero", email = "ajcardoza2000@gmail.com" }]
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["azure-storage-blob>=12.25.1"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["banortegpt"]
|
||||
208
packages/chat-ui/README.md
Normal file
208
packages/chat-ui/README.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# @banorte/chat-ui
|
||||
|
||||
A decoupled React chat UI component library with Tailwind CSS styling.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @banorte/chat-ui
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Add the Tailwind Plugin
|
||||
|
||||
To ensure all the necessary CSS classes are included, add the chat-ui Tailwind plugin to your `tailwind.config.js`:
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
// ... your other content paths
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [
|
||||
require("daisyui"),
|
||||
require("@banorte/chat-ui/tailwind"), // Add this line
|
||||
],
|
||||
// ... rest of your config
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Install Required Dependencies
|
||||
|
||||
Make sure you have the following peer dependencies installed:
|
||||
|
||||
```bash
|
||||
npm install react react-dom @iconify-icon/react
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### Chat
|
||||
|
||||
The main chat interface component.
|
||||
|
||||
```tsx
|
||||
import { Chat } from "@banorte/chat-ui";
|
||||
|
||||
function App() {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [conversationId, setConversationId] = useState("");
|
||||
const [receivingMsg, setReceivingMsg] = useState(false);
|
||||
|
||||
const pushMessage = (message) => {
|
||||
setMessages(prev => [...prev, message]);
|
||||
};
|
||||
|
||||
const handleStartConversation = async (user, assistant) => {
|
||||
const response = await fetch("/api/v1/conversation", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user, assistant }),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data.conversation_id;
|
||||
};
|
||||
|
||||
const handleFeedback = async (key, rating) => {
|
||||
await fetch("/api/v1/feedback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key, rating }),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Chat
|
||||
assistant="Maya"
|
||||
messages={messages}
|
||||
pushMessage={pushMessage}
|
||||
conversationId={conversationId}
|
||||
setConversationId={setConversationId}
|
||||
setAssistantName={(name) => console.log("Assistant:", name)}
|
||||
receivingMsg={receivingMsg}
|
||||
setReceivingMsg={setReceivingMsg}
|
||||
onStartConversation={handleStartConversation}
|
||||
sendIcon="/path/to/send-icon.png"
|
||||
userAvatar="/path/to/user-avatar.png"
|
||||
botAvatar="/path/to/bot-avatar.png"
|
||||
onFeedback={handleFeedback} // Optional
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### ChatSidebar
|
||||
|
||||
A sidebar component for the chat interface.
|
||||
|
||||
```tsx
|
||||
import { ChatSidebar } from "@banorte/chat-ui";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ChatSidebar
|
||||
assistant="Maya"
|
||||
logoSrc="/path/to/logo.png"
|
||||
sidebarImageSrc="/path/to/sidebar-image.png"
|
||||
assistantAvatarSrc="/path/to/assistant-avatar.png"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### FeedbackButton
|
||||
|
||||
A standalone feedback component.
|
||||
|
||||
```tsx
|
||||
import { FeedbackButton } from "@banorte/chat-ui";
|
||||
|
||||
function MessageComponent() {
|
||||
const handleFeedback = async (key, rating) => {
|
||||
// Handle feedback submission
|
||||
console.log("Feedback:", key, rating);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Some message content...</p>
|
||||
<FeedbackButton
|
||||
messageKey="message-123"
|
||||
onFeedback={handleFeedback}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
### Chat Props
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| `assistant` | `string` | Yes | Name of the assistant |
|
||||
| `messages` | `Message[]` | Yes | Array of chat messages |
|
||||
| `pushMessage` | `(message: Message) => void` | Yes | Function to add new messages |
|
||||
| `conversationId` | `string` | Yes | Current conversation ID |
|
||||
| `setConversationId` | `(id: string) => void` | Yes | Function to set conversation ID |
|
||||
| `setAssistantName` | `(name: string) => void` | Yes | Function to set assistant name |
|
||||
| `receivingMsg` | `boolean` | Yes | Whether currently receiving a message |
|
||||
| `setReceivingMsg` | `(receiving: boolean) => void` | Yes | Function to update receiving state |
|
||||
| `onStartConversation` | `(user: string, assistant: string) => Promise<string>` | Yes | Function to start a new conversation |
|
||||
| `sendIcon` | `string` | Yes | Image source for send button |
|
||||
| `userAvatar` | `string` | Yes | User avatar image source |
|
||||
| `botAvatar` | `string` | Yes | Bot avatar image source |
|
||||
| `onFeedback` | `(key: string, rating: string) => Promise<void>` | No | Optional feedback handler |
|
||||
|
||||
### ChatSidebar Props
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| `assistant` | `string` | Yes | Name of the assistant |
|
||||
| `logoSrc` | `string` | Yes | Logo image source |
|
||||
| `sidebarImageSrc` | `string` | Yes | Sidebar image source |
|
||||
| `assistantAvatarSrc` | `string` | Yes | Assistant avatar image source |
|
||||
|
||||
### FeedbackButton Props
|
||||
|
||||
| Prop | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| `messageKey` | `string` | Yes | Unique identifier for the message |
|
||||
| `onFeedback` | `(key: string, rating: string) => Promise<void>` | Yes | Callback function for feedback submission |
|
||||
|
||||
## Message Type
|
||||
|
||||
```typescript
|
||||
interface Message {
|
||||
user: boolean;
|
||||
content: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Decoupled Architecture**: Components accept all dependencies as props
|
||||
- **TypeScript Support**: Full TypeScript definitions included
|
||||
- **Tailwind CSS**: Styled with Tailwind CSS classes
|
||||
- **DaisyUI Integration**: Uses DaisyUI components for consistent styling
|
||||
- **Responsive Design**: Mobile-friendly responsive layout
|
||||
- **Image Support**: Built-in image viewer for AI-generated images
|
||||
- **Feedback System**: Optional feedback collection for messages
|
||||
- **Streaming Support**: Real-time message streaming via SSE
|
||||
- **Markdown Support**: Rich text rendering with markdown support
|
||||
|
||||
## Requirements
|
||||
|
||||
- React 18+
|
||||
- Tailwind CSS 3+
|
||||
- DaisyUI plugin for Tailwind CSS
|
||||
- Node.js 16+
|
||||
|
||||
## License
|
||||
|
||||
Private package - All rights reserved.
|
||||
39
packages/chat-ui/package.json
Normal file
39
packages/chat-ui/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@banorte/chat-ui",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "dist/index.mjs",
|
||||
"types": "dist/index.d.mts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs"
|
||||
},
|
||||
"./tailwind": "./src/tailwind-plugin.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-spring": "^9.7.4",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"sse.js": "^2.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-icon/react": "^2.1.0",
|
||||
"@types/react": "^18.2.67",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@typescript-eslint/eslint-plugin": "^7.3.1",
|
||||
"@typescript-eslint/parser": "^7.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.4.3"
|
||||
}
|
||||
}
|
||||
123
packages/chat-ui/src/components/Chat.tsx
Normal file
123
packages/chat-ui/src/components/Chat.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { FormEvent, useState, useEffect } from "react";
|
||||
import { ChatMessage } from "./ChatMessage";
|
||||
import { useRef } from "react";
|
||||
|
||||
export { Chat };
|
||||
|
||||
interface Message {
|
||||
user: boolean;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface ChatProps {
|
||||
assistant: string;
|
||||
messages: Message[];
|
||||
pushMessage: (message: Message) => void;
|
||||
conversationId: string;
|
||||
setConversationId: (id: string) => void;
|
||||
setAssistantName: (name: string) => void;
|
||||
receivingMsg: boolean;
|
||||
setReceivingMsg: (receiving: boolean) => void;
|
||||
onStartConversation: (user: string, assistant: string) => Promise<string>;
|
||||
sendIcon: string;
|
||||
userAvatar: string;
|
||||
botAvatar: string;
|
||||
onFeedback?: (key: string, rating: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function Chat({
|
||||
assistant,
|
||||
messages,
|
||||
pushMessage,
|
||||
conversationId,
|
||||
setConversationId,
|
||||
setAssistantName,
|
||||
receivingMsg,
|
||||
setReceivingMsg,
|
||||
onStartConversation,
|
||||
sendIcon,
|
||||
userAvatar,
|
||||
botAvatar,
|
||||
onFeedback
|
||||
}: ChatProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const bottomRef = useRef(null);
|
||||
|
||||
async function startConversation() {
|
||||
const newId = await onStartConversation("user", assistant);
|
||||
setConversationId(newId);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setAssistantName(assistant);
|
||||
startConversation();
|
||||
}, []);
|
||||
|
||||
function changeInput(e: FormEvent<HTMLInputElement>) {
|
||||
e.preventDefault();
|
||||
setInput(e.currentTarget.value);
|
||||
}
|
||||
|
||||
async function clearInput(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
// Validar que el input no esté vacío
|
||||
const trimmedInput = input.trim();
|
||||
if (!trimmedInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
pushMessage({ user: true, content: trimmedInput });
|
||||
setInput("");
|
||||
pushMessage({ user: false, content: trimmedInput });
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
// @ts-expect-error idk
|
||||
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center bg-slate-100 h-screen">
|
||||
<div className="mt-5 w-3/5 flex-1 overflow-y-auto scrollbar min-h-0">
|
||||
{messages.map((message, index) => (
|
||||
<ChatMessage
|
||||
key={index}
|
||||
isUser={message.user}
|
||||
content={message.content}
|
||||
event={scrollToBottom}
|
||||
conversationId={conversationId}
|
||||
setReceivingMsg={setReceivingMsg}
|
||||
userAvatar={userAvatar}
|
||||
botAvatar={botAvatar}
|
||||
onFeedback={onFeedback}
|
||||
/>
|
||||
))}
|
||||
<div ref={bottomRef}></div>
|
||||
</div>
|
||||
<form
|
||||
className="flex-shrink-0 ml-5 my-5 flex w-3/4 items-center justify-center mr-5"
|
||||
onSubmit={clearInput}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={changeInput}
|
||||
disabled={receivingMsg}
|
||||
placeholder="¡Pregúntame algo!"
|
||||
className="input input-bordered focus:input-primary w-[90%] p-7 rounded-3xl"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn-error ml-4 hover:border-red-200 hover:opacity-80 ${
|
||||
!input.trim() ? "opacity-50" : ""
|
||||
}`}
|
||||
disabled={receivingMsg || !input.trim()}
|
||||
>
|
||||
<img src={sendIcon} alt="Send" className="h-14 w-14" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
393
packages/chat-ui/src/components/ChatMessage.tsx
Normal file
393
packages/chat-ui/src/components/ChatMessage.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { FeedbackButton } from "./FeedbackButton";
|
||||
import { PDFModal } from "./PDFModal";
|
||||
import Markdown from "react-markdown";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import { SSE } from "sse.js";
|
||||
|
||||
export { ChatMessage };
|
||||
|
||||
interface ChatMessageProps {
|
||||
isUser: boolean;
|
||||
content: string;
|
||||
event: CallableFunction;
|
||||
conversationId: string;
|
||||
setReceivingMsg: (receiving: boolean) => void;
|
||||
userAvatar: string;
|
||||
botAvatar: string;
|
||||
onFeedback?: (key: string, rating: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function ChatMessage({
|
||||
isUser,
|
||||
content,
|
||||
event,
|
||||
conversationId,
|
||||
setReceivingMsg,
|
||||
userAvatar,
|
||||
botAvatar,
|
||||
onFeedback
|
||||
}: ChatMessageProps) {
|
||||
const [buff, setBuff] = useState("");
|
||||
const [responseId, setResponseId] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
||||
const [acceptFeedback, setAcceptFeedback] = useState(false);
|
||||
const [streamIndex, setStreamIndex] = useState(0);
|
||||
const [fullResponse, setFullResponse] = useState("");
|
||||
const [pendingReferences, setPendingReferences] = useState<Array<any>>([]);
|
||||
const [streamingComplete, setStreamingComplete] = useState(false);
|
||||
|
||||
const [pdfModal, setPdfModal] = useState({
|
||||
isOpen: false,
|
||||
filename: '',
|
||||
page: undefined as number | undefined
|
||||
});
|
||||
|
||||
|
||||
|
||||
const closePdfModal = () => {
|
||||
setPdfModal({
|
||||
isOpen: false,
|
||||
filename: '',
|
||||
page: undefined
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && pdfModal.isOpen) {
|
||||
closePdfModal();
|
||||
}
|
||||
};
|
||||
|
||||
if (pdfModal.isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
}, [pdfModal.isOpen]);
|
||||
|
||||
const nextImage = () => {
|
||||
if (currentImageIndex < images.length - 1) {
|
||||
setCurrentImageIndex((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const prevImage = () => {
|
||||
if (currentImageIndex > 0) {
|
||||
setCurrentImageIndex((prev) => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (fullResponse && streamIndex < fullResponse.length) {
|
||||
setLoading(false);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setBuff((prev) => prev + fullResponse[streamIndex]);
|
||||
setStreamIndex((prev) => prev + 1);
|
||||
event();
|
||||
}, 3);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
} else if (fullResponse && streamIndex === fullResponse.length) {
|
||||
setReceivingMsg(false);
|
||||
setStreamingComplete(true);
|
||||
setBuff(fullResponse);
|
||||
}
|
||||
}, [fullResponse, streamIndex]);
|
||||
|
||||
async function getStream() {
|
||||
const payload = JSON.stringify({
|
||||
prompt: content,
|
||||
conversation_id: conversationId,
|
||||
});
|
||||
|
||||
const url = "/api/v1/message?stream=True";
|
||||
const eventSource = new SSE(url, {
|
||||
withCredentials: true,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
payload: payload,
|
||||
});
|
||||
|
||||
eventSource.onmessage = async (event) => {
|
||||
console.log(event.data);
|
||||
const ResponseChunk = JSON.parse(event.data);
|
||||
|
||||
if (ResponseChunk["type"] === "text") {
|
||||
const content = ResponseChunk["content"];
|
||||
setFullResponse((prev) => prev + content);
|
||||
} else if (ResponseChunk["type"] === "reference") {
|
||||
setPendingReferences(ResponseChunk["content"]);
|
||||
} else if (ResponseChunk["type"] === "end") {
|
||||
setResponseId(ResponseChunk["content"]);
|
||||
eventSource.close();
|
||||
} else if (ResponseChunk["type"] === "image") {
|
||||
const newImages = ResponseChunk.content.slice(0, 3);
|
||||
setImages((prev) => {
|
||||
const combinedImages = [...prev, ...newImages];
|
||||
return combinedImages.slice(0, 3);
|
||||
});
|
||||
} else if (ResponseChunk["type"] == "tool") {
|
||||
setAcceptFeedback(true);
|
||||
} else if (ResponseChunk["type"] === "error") {
|
||||
setFullResponse((prev) => prev + "\n\n" + ResponseChunk["content"]);
|
||||
eventSource.close();
|
||||
}
|
||||
};
|
||||
eventSource.onerror = async (e) => {
|
||||
console.log("error" + e);
|
||||
setReceivingMsg(false);
|
||||
setLoading(false);
|
||||
eventSource.close();
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUser) {
|
||||
setLoading(true);
|
||||
setReceivingMsg(true);
|
||||
getStream();
|
||||
} else {
|
||||
setBuff(content);
|
||||
event();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const Metadata = ({ metadatas }: { metadatas: any[] }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (!metadatas || metadatas.length === 0) return null;
|
||||
|
||||
// Mapeo de archivos a URLs públicas (mismo que en el backend)
|
||||
const PDF_PUBLIC_URLS: { [key: string]: string } = {
|
||||
"Disposiciones de carácter general aplicables a las casas de bolsa.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20aplicables%20a%20las%20casas%20de%20bolsa.pdf",
|
||||
"Disposiciones de carácter general aplicables a las instituciones de crédito.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20aplicables%20a%20las%20instituciones%20de%20cr%C3%A9dito.pdf",
|
||||
"Disposiciones de carácter general aplicables a las sociedades controladoras de grupos financieros y subcontroladoras que regulan las materias que corresponden de manera conjunta a las Comisio.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20aplicables%20a%20las%20sociedades%20controladoras%20de%20grupos%20financieros%20y%20subcontroladoras%20que%20regulan%20las%20materias%20que%20corresponden%20de%20manera%20conjunta%20a%20las%20Comisiones%20Nacionales%20Supervisoras.pdf",
|
||||
"Disposiciones de carácter general aplicables a los fondos de inversión y a las personas que les prestan servicios.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20aplicables%20a%20los%20fondos%20de%20inversi%C3%B3n%20y%20a%20las%20personas%20que%20les%20prestan%20servicios.pdf",
|
||||
"Ley para la Transparencia y Ordenamiento de los Servicios Financieros.pdf": "https://www.cnbv.gob.mx/Normatividad/Ley%20para%20la%20Transparencia%20y%20Ordenamiento%20de%20los%20Servicios%20Financieros.pdf",
|
||||
"circular_servicios_de_inversion.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20aplicables%20a%20las%20entidades%20financieras%20y%20dem%C3%A1s%20personas%20que%20proporcionen%20servicios%20de.pdf",
|
||||
"circular_unica_de_auditores_externos.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20que%20establecen%20los%20requisitos%20que%20deber%C3%A1n%20cumplir%20los%20auditores%20y%20otros%20profesionales%20que.pdf",
|
||||
"ley_de_instituciones_de_Credito.pdf": "https://www.cnbv.gob.mx/Normatividad/Ley%20de%20Instituciones%20de%20Cr%C3%A9dito.pdf",
|
||||
"circular_13_2007.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-13-2007/cobro-intereses-por-adelantad.html",
|
||||
"circular_13_2011.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-13-2011/%7BBA4CBC28-A468-16C9-6F17-9EA9D7B03318%7D.pdf",
|
||||
"circular_14_2007.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-14-2007/%7BFB726B6B-D523-56F5-F9B1-BE5B3B95A504%7D.pdf",
|
||||
"circular_17_2014.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-17-2014/%7BF36CEF03-9441-2DBE-082C-0DF274903782%7D.pdf",
|
||||
"circular_1_2005.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-1-2005/%7B5CA4BA75-FEA8-199C-F129-E8E6A73E84F3%7D.pdf",
|
||||
"circular_21_2009.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-21-2009/%7B29285862-EDE0-567A-BAFB-D261406641A3%7D.pdf",
|
||||
"circular_22_2008.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-22-2008/%7BF15C8A26-C92E-BE2B-9344-51EDAA3C9B68%7D.pdf",
|
||||
"circular_22_2010.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-22-2010/%7B0D531F59-1001-4D67-D7B4-D5854DD07A58%7D.pdf",
|
||||
"circular_27_2008.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-27-2008/%7BBC4333FE-070F-E727-199E-CA6BCF2CBA66%7D.pdf",
|
||||
"circular_34_2010.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-34-2010/%7B0C55B906-6DB4-6B88-FED0-67987E9FB3CC%7D.pdf",
|
||||
"circular_35_2010.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-35-2010/%7B74C5641C-ED98-53C7-F08B-A3C7BAE0D480%7D.pdf",
|
||||
"circular_36_2010.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-36-2010/%7B26C55DE6-CC3A-3368-34FC-1A6C50B11130%7D.pdf",
|
||||
"circular_3_2012.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-3-2012/%7B4E0281A4-7AD8-1462-BC79-7F2925F3171D%7D.pdf",
|
||||
"circular_4_2012.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-4-2012/%7B97C62974-1C94-19AE-AB5A-D0D949A36247%7D.pdf",
|
||||
"circular_unica_de_condusef.pdf": "https://www.condusef.gob.mx/documentos/marco_legal/disposiciones-transparencia-if-sofom.pdf",
|
||||
"ley_para_regular_las_sociedades_de_informacion_crediticia.pdf": "https://www.condusef.gob.mx/documentos/marco_legal/disposiciones-transparencia-if-sofom.pdf",
|
||||
"ley_federal_de_proteccion_de_datos_personales_en_posesion_de_los_particulares.pdf": "https://www.diputados.gob.mx/LeyesBiblio/pdf/LFPDPPP.pdf",
|
||||
"reglamento_de_la_ley_federal_de_proteccion_de_datos_personales_en_posesion_de_los_particulares.pdf": "https://www.diputados.gob.mx/LeyesBiblio/regley/Reg_LFPDPPP.pdf",
|
||||
"Modificaciones Recursos Procedencia Ilícita jul 25 PLD.pdf": "https://gfbanorte.sharepoint.com/:w:/r/sites/Formatosyplantillas/Documentos%20compartidos/Otros/Modificaciones%20Recursos%20Procedencia%20Il%C3%ADcita%20jul%2025%20PLD.docx?d=w6a941e9e2c26403ea41c12de35536516&csf=1&web=1&e=EHtc9b",
|
||||
};
|
||||
|
||||
const handlePdfClick = (fileName: string, page?: number) => {
|
||||
const publicUrl = PDF_PUBLIC_URLS[fileName];
|
||||
|
||||
if (publicUrl) {
|
||||
// Abrir PDF público directamente
|
||||
let finalUrl = publicUrl;
|
||||
if (page) {
|
||||
finalUrl += `#page=${page}`;
|
||||
}
|
||||
window.open(finalUrl, '_blank');
|
||||
} else {
|
||||
// Fallback: usar tu modal para PDFs locales
|
||||
setPdfModal({
|
||||
isOpen: true,
|
||||
filename: fileName,
|
||||
page: page
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<span className="font-medium text-gray-700 mr-2">Fuentes</span>
|
||||
<span className="text-gray-500">
|
||||
{isExpanded ? '▲' : '▼'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-3 border border-gray-300 rounded-lg bg-gray-50 p-4">
|
||||
<div className="space-y-2">
|
||||
{metadatas.map((metadata, index) => {
|
||||
const fileName = metadata.file_name || metadata.file || 'Documento';
|
||||
const page = metadata.page;
|
||||
const displayText = page ? `${fileName} - Página ${page}` : fileName;
|
||||
const isPublic = PDF_PUBLIC_URLS[fileName];
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-start space-x-2 text-sm">
|
||||
<span className="text-gray-400 mt-1">
|
||||
{isPublic ? '🌐' : '📄'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handlePdfClick(fileName, page)}
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline cursor-pointer text-left flex items-center"
|
||||
title={isPublic ? 'Documento público - Se abrirá en nueva pestaña' : 'Documento local'}
|
||||
>
|
||||
{displayText}
|
||||
{isPublic && (
|
||||
<span className="ml-1 text-xs text-gray-500">↗</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ImageViewer = () => {
|
||||
if (images.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-5 space-y-4">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={images[currentImageIndex]}
|
||||
alt={`Generated image ${currentImageIndex + 1}`}
|
||||
className="w-full h-auto rounded-lg"
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<button
|
||||
onClick={prevImage}
|
||||
disabled={currentImageIndex === 0}
|
||||
className={`px-4 py-2 rounded ${
|
||||
currentImageIndex === 0
|
||||
? "text-gray-400 cursor-not-allowed"
|
||||
: "text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
onClick={nextImage}
|
||||
disabled={currentImageIndex === images.length - 1}
|
||||
className={`px-4 py-2 rounded ${
|
||||
currentImageIndex === images.length - 1
|
||||
? "text-gray-400 cursor-not-allowed"
|
||||
: "text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600 mt-2 block text-center">
|
||||
Imagen {currentImageIndex + 1} de {images.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isUser ? (
|
||||
<div className="m-5 mr-5 flex flex-row-reverse items-start space-x-4">
|
||||
<div className="avatar placeholder mx-4 w-14 -mt-1">
|
||||
<img src={userAvatar} alt="user avatar icon" />
|
||||
</div>
|
||||
<div className="inline-block max-w-[82%] 2xl:max-w-[88%]">
|
||||
<div className="border border-slate-400 rounded-3xl bg-white p-4 text-gray-500">
|
||||
<div className="whitespace-pre-wrap text-left">
|
||||
{loading && (
|
||||
<span className="loading loading-dots loading-md"></span>
|
||||
)}
|
||||
<Markdown rehypePlugins={[rehypeRaw]}>{buff}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="m-5 flex items-start space-x-4 w-full">
|
||||
<div className="avatar placeholder mx-4 w-14 -mt-1 mr-2">
|
||||
<img src={botAvatar} alt="bot avatar icon" />
|
||||
</div>
|
||||
<div className="inline-block max-w-[82%] 2xl:max-w-[88%]">
|
||||
<div className="border-2 border-red-500 rounded-3xl bg-white p-4 text-gray-500 pl-6">
|
||||
<div className="flex flex-col items-start">
|
||||
<div className="text-left w-full">
|
||||
{loading && (
|
||||
<span className="loading loading-dots loading-md"></span>
|
||||
)}
|
||||
<Markdown
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
components={{
|
||||
h1: ({ ...props }) => (
|
||||
<h1 className="text-2xl font-bold mb-4" {...props} />
|
||||
),
|
||||
h2: ({ ...props }) => (
|
||||
<h2 className="text-xl font-bold mb-3" {...props} />
|
||||
),
|
||||
h3: ({ ...props }) => (
|
||||
<h3 className="text-lg font-bold mb-2" {...props} />
|
||||
),
|
||||
p: ({ ...props }) => <p className="mb-4" {...props} />,
|
||||
ul: ({ ...props }) => (
|
||||
<ul
|
||||
className="list-disc pl-6 mb-4 space-y-2"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ol: ({ ...props }) => (
|
||||
<ol
|
||||
className="list-decimal pl-6 mb-4 space-y-2"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
li: ({ ...props }) => <li className="mb-1" {...props} />,
|
||||
a: ({ ...props }) => (
|
||||
<a
|
||||
className="text-blue-600 underline hover:text-blue-800"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
strong: ({ ...props }) => (
|
||||
<strong className="font-bold" {...props} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{buff}
|
||||
</Markdown>
|
||||
{pendingReferences.length > 0 && <Metadata metadatas={pendingReferences} />}
|
||||
<ImageViewer />
|
||||
</div>
|
||||
{streamingComplete && acceptFeedback && onFeedback && (
|
||||
<FeedbackButton messageKey={responseId} onFeedback={onFeedback} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PDFModal
|
||||
isOpen={pdfModal.isOpen}
|
||||
onClose={closePdfModal}
|
||||
filename={pdfModal.filename}
|
||||
page={pdfModal.page}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
49
packages/chat-ui/src/components/ChatSidebar.tsx
Normal file
49
packages/chat-ui/src/components/ChatSidebar.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
export { ChatSidebar };
|
||||
|
||||
interface ChatSidebarProps {
|
||||
assistant: string;
|
||||
logoSrc: string;
|
||||
sidebarImageSrc: string;
|
||||
assistantAvatarSrc: string;
|
||||
}
|
||||
|
||||
function ChatSidebar({ assistant, logoSrc, sidebarImageSrc, assistantAvatarSrc }: ChatSidebarProps) {
|
||||
return (
|
||||
<>
|
||||
<nav className="bg-[#1b0103] shadow-lg min-h-[641px] min-w-[250px] py-6 px-6 font-[sans-serif] flex flex-col overflow-auto w-[272px] 2xl:h-screen">
|
||||
<div className="flex flex-wrap items-center cursor-pointer">
|
||||
<div className="relative w-full mb-12 ">
|
||||
<div className="mx-5 w-3/4 -inset-3mt-2">
|
||||
<a href="/">
|
||||
<img className="h-10" src={logoSrc} alt="Logo" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative items-center text-center mx-auto -mt-5">
|
||||
<img src={assistantAvatarSrc} className="w-24 h-24 border-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto mt-2">
|
||||
<h2 className="text-xl font-extrabold text-gray-300 uppercase">
|
||||
{assistant}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3 flex-1 mt-5 mb-10 pl-5">
|
||||
</ul>
|
||||
<ul className="w-full">
|
||||
<li className="w-full">
|
||||
<a
|
||||
href=""
|
||||
className="text-gray-300 hover:text-white text-base flex items-center rounded-md"
|
||||
>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="w-[272px] -p-6 -m-6">
|
||||
<img src={sidebarImageSrc} alt="Sidebar Image" className="w-[272px]" />
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
69
packages/chat-ui/src/components/FeedbackButton.tsx
Normal file
69
packages/chat-ui/src/components/FeedbackButton.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Icon } from "@iconify-icon/react";
|
||||
import { useState } from "react";
|
||||
|
||||
export { FeedbackButton };
|
||||
|
||||
interface FeedbackButtonProps {
|
||||
messageKey: string;
|
||||
onFeedback: (key: string, rating: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function FeedbackButton({ messageKey, onFeedback }: FeedbackButtonProps) {
|
||||
const [likeStyle, setLikeStyle] = useState({ fontSize: "18px" });
|
||||
const [dislikeStyle, setDislikeStyle] = useState({ fontSize: "18px" });
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [disliked, setDisliked] = useState(false);
|
||||
|
||||
async function sendFeedback(rating: string) {
|
||||
await onFeedback(messageKey, rating);
|
||||
console.log("Sent feedback:");
|
||||
console.log(rating);
|
||||
}
|
||||
|
||||
function Like() {
|
||||
if (!liked) {
|
||||
const colorStyle = { color: "green" };
|
||||
setLikeStyle({ fontSize: "18px", ...colorStyle });
|
||||
setLiked(true);
|
||||
|
||||
setDislikeStyle({ fontSize: "18px" });
|
||||
setDisliked(false);
|
||||
|
||||
sendFeedback("Good");
|
||||
} else {
|
||||
setLikeStyle({ fontSize: "18px" });
|
||||
setLiked(false);
|
||||
|
||||
sendFeedback("None");
|
||||
}
|
||||
}
|
||||
|
||||
function Dislike() {
|
||||
if (!disliked) {
|
||||
const colorStyle = { color: "red" };
|
||||
setDislikeStyle({ fontSize: "18px", ...colorStyle });
|
||||
setDisliked(true);
|
||||
|
||||
setLikeStyle({ fontSize: "18px" });
|
||||
setLiked(false);
|
||||
|
||||
sendFeedback("Bad");
|
||||
} else {
|
||||
setDislikeStyle({ fontSize: "18px" });
|
||||
setDisliked(false);
|
||||
|
||||
sendFeedback("None");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-end space-x-2 mt-3">
|
||||
<button className="btn btn-xs btn-ghost" onClick={Like}>
|
||||
<Icon style={likeStyle} icon="iconamoon:like" />
|
||||
</button>
|
||||
<button className="btn btn-xs btn-ghost" onClick={Dislike}>
|
||||
<Icon style={dislikeStyle} flip="horizontal" icon="iconamoon:dislike" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
381
packages/chat-ui/src/components/PDFModal.tsx
Normal file
381
packages/chat-ui/src/components/PDFModal.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface PDFModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
filename: string;
|
||||
page?: number;
|
||||
}
|
||||
|
||||
// Mapeo COMPLETO con URLs públicas directas - IDÉNTICO AL BACKEND
|
||||
const PDF_REFERENCES: Record<string, string> = {
|
||||
// Disposiciones de CNBV
|
||||
"Disposiciones de carácter general aplicables a las casas de bolsa.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20aplicables%20a%20las%20casas%20de%20bolsa.pdf",
|
||||
"Disposiciones de carácter general aplicables a las instituciones de crédito.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20aplicables%20a%20las%20instituciones%20de%20cr%C3%A9dito.pdf",
|
||||
"Disposiciones de carácter general aplicables a las sociedades controladoras de grupos financieros y subcontroladoras que regulan las materias que corresponden de manera conjunta a las Comisio.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20aplicables%20a%20las%20sociedades%20controladoras%20de%20grupos%20financieros%20y%20subcontroladoras%20que%20regulan%20las%20materias%20que%20corresponden%20de%20manera%20conjunta%20a%20las%20Comisiones%20Nacionales%20Supervisoras.pdf",
|
||||
"Disposiciones de carácter general aplicables a los fondos de inversión y a las personas que les prestan servicios.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20aplicables%20a%20los%20fondos%20de%20inversi%C3%B3n%20y%20a%20las%20personas%20que%20les%20prestan%20servicios.pdf",
|
||||
"Ley para la Transparencia y Ordenamiento de los Servicios Financieros.pdf": "https://www.cnbv.gob.mx/Normatividad/Ley%20para%20la%20Transparencia%20y%20Ordenamiento%20de%20los%20Servicios%20Financieros.pdf",
|
||||
|
||||
// Circulares CNBV adicionales
|
||||
"circular_servicios_de_inversion.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20aplicables%20a%20las%20entidades%20financieras%20y%20dem%C3%A1s%20personas%20que%20proporcionen%20servicios%20de.pdf",
|
||||
"circular_unica_de_auditores_externos.pdf": "https://www.cnbv.gob.mx/Normatividad/Disposiciones%20de%20car%C3%A1cter%20general%20que%20establecen%20los%20requisitos%20que%20deber%C3%A1n%20cumplir%20los%20auditores%20y%20otros%20profesionales%20que.pdf",
|
||||
"ley_de_instituciones_de_Credito.pdf": "https://www.cnbv.gob.mx/Normatividad/Ley%20de%20Instituciones%20de%20Cr%C3%A9dito.pdf",
|
||||
|
||||
// Circulares de Banxico
|
||||
"circular_13_2007.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-13-2007/cobro-intereses-por-adelantad.html",
|
||||
"circular_13_2011.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-13-2011/%7BBA4CBC28-A468-16C9-6F17-9EA9D7B03318%7D.pdf",
|
||||
"circular_14_2007.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-14-2007/%7BFB726B6B-D523-56F5-F9B1-BE5B3B95A504%7D.pdf",
|
||||
"circular_17_2014.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-17-2014/%7BF36CEF03-9441-2DBE-082C-0DF274903782%7D.pdf",
|
||||
"circular_1_2005.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-1-2005/%7B5CA4BA75-FEA8-199C-F129-E8E6A73E84F3%7D.pdf",
|
||||
"circular_21_2009.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-21-2009/%7B29285862-EDE0-567A-BAFB-D261406641A3%7D.pdf",
|
||||
"circular_22_2008.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-22-2008/%7BF15C8A26-C92E-BE2B-9344-51EDAA3C9B68%7D.pdf",
|
||||
"circular_22_2010.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-22-2010/%7B0D531F59-1001-4D67-D7B4-D5854DD07A58%7D.pdf",
|
||||
"circular_27_2008.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-27-2008/%7BBC4333FE-070F-E727-199E-CA6BCF2CBA66%7D.pdf",
|
||||
"circular_34_2010.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-34-2010/%7B0C55B906-6DB4-6B88-FED0-67987E9FB3CC%7D.pdf",
|
||||
"circular_35_2010.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-35-2010/%7B74C5641C-ED98-53C7-F08B-A3C7BAE0D480%7D.pdf",
|
||||
"circular_36_2010.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-36-2010/%7B26C55DE6-CC3A-3368-34FC-1A6C50B11130%7D.pdf",
|
||||
"circular_3_2012.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-3-2012/%7B4E0281A4-7AD8-1462-BC79-7F2925F3171D%7D.pdf",
|
||||
"circular_4_2012.pdf": "https://www.banxico.org.mx/marco-normativo/normativa-emitida-por-el-banco-de-mexico/circular-4-2012/%7B97C62974-1C94-19AE-AB5A-D0D949A36247%7D.pdf",
|
||||
|
||||
// CONDUSEF
|
||||
"circular_unica_de_condusef.pdf": "https://www.condusef.gob.mx/documentos/marco_legal/disposiciones-transparencia-if-sofom.pdf",
|
||||
"ley_para_regular_las_sociedades_de_informacion_crediticia.pdf": "https://www.condusef.gob.mx/documentos/marco_legal/disposiciones-transparencia-if-sofom.pdf",
|
||||
|
||||
// Leyes federales
|
||||
"ley_federal_de_proteccion_de_datos_personales_en_posesion_de_los_particulares.pdf": "https://www.diputados.gob.mx/LeyesBiblio/pdf/LFPDPPP.pdf",
|
||||
"reglamento_de_la_ley_federal_de_proteccion_de_datos_personales_en_posesion_de_los_particulares.pdf": "https://www.diputados.gob.mx/LeyesBiblio/regley/Reg_LFPDPPP.pdf",
|
||||
|
||||
// SharePoint Banorte
|
||||
"Modificaciones Recursos Procedencia Ilícita jul 25 PLD.pdf": "https://gfbanorte.sharepoint.com/:w:/r/sites/Formatosyplantillas/Documentos%20compartidos/Otros/Modificaciones%20Recursos%20Procedencia%20Il%C3%ADcita%20jul%2025%20PLD.docx?d=w6a941e9e2c26403ea41c12de35536516&csf=1&web=1&e=EHtc9b",
|
||||
};
|
||||
|
||||
// Función para determinar si es una URL externa
|
||||
const isExternalUrl = (url: string): boolean => {
|
||||
return url.startsWith('http://') || url.startsWith('https://');
|
||||
};
|
||||
|
||||
// Función para resolver la URL del PDF - BYPASS completo del backend local
|
||||
const resolvePdfUrl = (filename: string): { directUrl: string; viewerUrl: string; isExternal: boolean } => {
|
||||
console.log(`Resolviendo PDF para: "${filename}"`);
|
||||
|
||||
// Buscar directamente en el mapeo de URLs públicas
|
||||
const publicUrl = PDF_REFERENCES[filename];
|
||||
|
||||
if (!publicUrl) {
|
||||
console.warn(`No se encontró URL pública para: "${filename}"`);
|
||||
return {
|
||||
directUrl: '',
|
||||
viewerUrl: '',
|
||||
isExternal: false
|
||||
};
|
||||
}
|
||||
|
||||
const external = isExternalUrl(publicUrl);
|
||||
|
||||
if (external) {
|
||||
console.log(`✅ BYPASS COMPLETO - URL pública directa: ${publicUrl}`);
|
||||
// PDF.js viewer apunta DIRECTAMENTE a la URL pública, sin pasar por tu API
|
||||
return {
|
||||
directUrl: publicUrl,
|
||||
viewerUrl: `https://mozilla.github.io/pdf.js/web/viewer.html?file=${encodeURIComponent(publicUrl)}`,
|
||||
isExternal: true
|
||||
};
|
||||
} else {
|
||||
console.warn(`URL no es externa: ${publicUrl}`);
|
||||
return {
|
||||
directUrl: '',
|
||||
viewerUrl: '',
|
||||
isExternal: false
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function PDFModal({ isOpen, onClose, filename, page }: PDFModalProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [portalElement, setPortalElement] = useState<HTMLElement | null>(null);
|
||||
const [pdfError, setPdfError] = useState(false);
|
||||
|
||||
// Resolver URLs del PDF
|
||||
const { directUrl: directPdfUrl, viewerUrl: pdfUrl, isExternal } = resolvePdfUrl(filename);
|
||||
|
||||
// Crear un elemento para el portal al montar el componente
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
let element = document.getElementById('pdf-modal-portal');
|
||||
|
||||
if (!element) {
|
||||
element = document.createElement('div');
|
||||
element.id = 'pdf-modal-portal';
|
||||
element.style.position = 'fixed';
|
||||
element.style.top = '0';
|
||||
element.style.left = '0';
|
||||
element.style.width = '100%';
|
||||
element.style.height = '100%';
|
||||
element.style.zIndex = '99999';
|
||||
element.style.pointerEvents = 'none';
|
||||
document.body.appendChild(element);
|
||||
}
|
||||
|
||||
setPortalElement(element);
|
||||
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
if (element && !isOpen && document.body.contains(element)) {
|
||||
document.body.removeChild(element);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
document.body.style.overflow = 'auto';
|
||||
setPdfError(false);
|
||||
setIsLoading(true);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Manejar tecla ESC
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen || !portalElement) return null;
|
||||
|
||||
const handleLoad = () => {
|
||||
setIsLoading(false);
|
||||
setPdfError(false);
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
setIsLoading(false);
|
||||
setPdfError(true);
|
||||
console.error(`Error loading PDF: ${filename}`, { directPdfUrl, pdfUrl, isExternal });
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setPdfError(false);
|
||||
setIsLoading(true);
|
||||
const iframe = document.querySelector(`iframe[title="PDF Viewer - ${filename}"]`) as HTMLIFrameElement;
|
||||
if (iframe) {
|
||||
iframe.src = pdfUrl;
|
||||
}
|
||||
};
|
||||
|
||||
// Si no hay URL pública, mostrar mensaje de error
|
||||
if (!directPdfUrl) {
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center p-4"
|
||||
style={{
|
||||
zIndex: 99999,
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div
|
||||
className="bg-white flex flex-col p-8 rounded-xl max-w-lg w-full"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">⚠️</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">PDF no disponible</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
No se encontró URL pública para: <strong>{filename}</strong>
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-gray-600 text-white font-medium rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
portalElement
|
||||
);
|
||||
}
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center p-4"
|
||||
style={{
|
||||
zIndex: 99999,
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div
|
||||
className="bg-white flex flex-col"
|
||||
style={{
|
||||
width: '85vw',
|
||||
height: '90vh',
|
||||
maxWidth: 'none',
|
||||
maxHeight: 'none',
|
||||
borderRadius: '16px',
|
||||
border: '3px solid #000000',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.05)',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-gradient-to-r from-gray-50 to-gray-100 border-b-2 border-gray-200">
|
||||
<div className="flex items-center space-x-3 min-w-0 flex-1">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||
<div className="ml-4 min-w-0 flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
📄 {filename}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-4 text-xs text-gray-500 mt-1">
|
||||
{page && <span>Página {page}</span>}
|
||||
<span className="px-2 py-1 rounded-full bg-blue-100 text-blue-700">
|
||||
Público
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded-full bg-green-100 text-green-700">
|
||||
Directo
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-red-500 text-2xl font-bold w-10 h-10 flex items-center justify-center hover:bg-red-50 rounded-full transition-all duration-200 flex-shrink-0 ml-4"
|
||||
aria-label="Cerrar modal"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* PDF Content */}
|
||||
<div className="flex-1 relative bg-gray-50">
|
||||
{isLoading && !pdfError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white z-10">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-16 w-16 border-b-4 border-blue-600"></div>
|
||||
<p className="mt-6 text-gray-600 text-lg font-medium">Cargando PDF...</p>
|
||||
<p className="mt-2 text-gray-400 text-sm max-w-md break-words">{filename}</p>
|
||||
<p className="mt-1 text-xs text-gray-300">Fuente pública</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pdfError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white z-10 p-8">
|
||||
<div className="text-center max-w-2xl">
|
||||
<div className="text-6xl mb-4">❌</div>
|
||||
<p className="text-red-600 text-lg font-medium mb-2">Error al cargar el PDF</p>
|
||||
<p className="text-gray-600 mb-4">
|
||||
No se pudo cargar: <strong className="break-words">{filename}</strong>
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-lg mb-4 text-left text-xs">
|
||||
<p className="font-medium text-gray-700 mb-2">Información:</p>
|
||||
<p className="text-gray-600 break-all mb-1">
|
||||
<strong>URL:</strong> {directPdfUrl}
|
||||
</p>
|
||||
<p className="text-gray-600">
|
||||
<strong>Tipo:</strong> Fuente pública
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-x-2">
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
<a
|
||||
href={directPdfUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Abrir en nueva pestaña
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!pdfError && (
|
||||
<iframe
|
||||
src={pdfUrl}
|
||||
className="w-full h-full border-0"
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
title={`PDF Viewer - ${filename}`}
|
||||
style={{
|
||||
minHeight: '100%',
|
||||
borderRadius: '0'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-gradient-to-r from-gray-50 to-gray-100 border-t-2 border-gray-200">
|
||||
<div className="flex items-center space-x-4">
|
||||
<a
|
||||
href={directPdfUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center px-4 py-2 bg-black text-white text-sm font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200"
|
||||
>
|
||||
🔗 Abrir en nueva pestaña
|
||||
</a>
|
||||
<span className="text-xs text-gray-500">
|
||||
ESC para cerrar • PDF.js Viewer • Fuente pública
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-gray-600 text-white font-medium rounded-lg hover:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, portalElement);
|
||||
}
|
||||
4
packages/chat-ui/src/index.ts
Normal file
4
packages/chat-ui/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./components/FeedbackButton"
|
||||
export * from "./components/ChatMessage"
|
||||
export * from "./components/Chat"
|
||||
export * from "./components/ChatSidebar"
|
||||
166
packages/chat-ui/src/tailwind-plugin.js
Normal file
166
packages/chat-ui/src/tailwind-plugin.js
Normal file
@@ -0,0 +1,166 @@
|
||||
import plugin from 'tailwindcss/plugin';
|
||||
|
||||
export default plugin(function({ addUtilities }) {
|
||||
// Add any custom utilities if needed
|
||||
}, {
|
||||
// Safelist all classes used in the chat-ui package
|
||||
safelist: [
|
||||
// Layout and flexbox
|
||||
'flex',
|
||||
'flex-1',
|
||||
'flex-col',
|
||||
'flex-row-reverse',
|
||||
'flex-wrap',
|
||||
'flex-shrink-0',
|
||||
'items-center',
|
||||
'items-start',
|
||||
'justify-center',
|
||||
'justify-between',
|
||||
'justify-end',
|
||||
'space-x-2',
|
||||
'space-x-4',
|
||||
'space-y-2',
|
||||
'space-y-3',
|
||||
'space-y-4',
|
||||
|
||||
// Positioning
|
||||
'relative',
|
||||
'absolute',
|
||||
'inset-3mt-2',
|
||||
'inline-block',
|
||||
|
||||
// Margins and padding
|
||||
'm-5',
|
||||
'mr-5',
|
||||
'ml-4',
|
||||
'ml-5',
|
||||
'my-5',
|
||||
'mx-4',
|
||||
'mx-5',
|
||||
'mx-auto',
|
||||
'mt-2',
|
||||
'mt-3',
|
||||
'mt-4',
|
||||
'mt-5',
|
||||
'-mt-1',
|
||||
'-mt-5',
|
||||
'mb-1',
|
||||
'mb-3',
|
||||
'mb-4',
|
||||
'mb-10',
|
||||
'mb-12',
|
||||
'mr-2',
|
||||
'p-4',
|
||||
'p-7',
|
||||
'px-4',
|
||||
'px-6',
|
||||
'py-2',
|
||||
'py-6',
|
||||
'pl-5',
|
||||
'pl-6',
|
||||
'-p-6',
|
||||
'-m-6',
|
||||
|
||||
// Width and height
|
||||
'w-full',
|
||||
'w-3/4',
|
||||
'w-3/5',
|
||||
'w-14',
|
||||
'w-24',
|
||||
'w-[90%]',
|
||||
'w-[272px]',
|
||||
'h-auto',
|
||||
'h-10',
|
||||
'h-14',
|
||||
'h-24',
|
||||
'h-screen',
|
||||
'min-h-0',
|
||||
'min-h-[641px]',
|
||||
'min-w-[250px]',
|
||||
'max-w-[82%]',
|
||||
|
||||
// Colors and backgrounds
|
||||
'bg-slate-100',
|
||||
'bg-white',
|
||||
'bg-[#1b0103]',
|
||||
'text-gray-300',
|
||||
'text-gray-500',
|
||||
'text-gray-600',
|
||||
'text-gray-700',
|
||||
'text-gray-400',
|
||||
'text-blue-600',
|
||||
'text-blue-700',
|
||||
'text-blue-800',
|
||||
'text-white',
|
||||
'text-base',
|
||||
'text-sm',
|
||||
'text-lg',
|
||||
'text-xl',
|
||||
'text-2xl',
|
||||
'border-white',
|
||||
'border-slate-400',
|
||||
'border-red-500',
|
||||
'border-2',
|
||||
'border',
|
||||
'hover:text-white',
|
||||
'hover:text-blue-800',
|
||||
'hover:bg-gray-100',
|
||||
'hover:border-red-200',
|
||||
'hover:opacity-80',
|
||||
|
||||
// Typography
|
||||
'font-bold',
|
||||
'font-extrabold',
|
||||
'font-[sans-serif]',
|
||||
'text-left',
|
||||
'text-center',
|
||||
'uppercase',
|
||||
'underline',
|
||||
'whitespace-pre-wrap',
|
||||
'list-disc',
|
||||
'list-decimal',
|
||||
|
||||
// Borders and rounding
|
||||
'rounded-lg',
|
||||
'rounded-md',
|
||||
'rounded-3xl',
|
||||
'rounded',
|
||||
|
||||
// Shadows and effects
|
||||
'shadow-lg',
|
||||
'opacity-50',
|
||||
|
||||
// Scrolling
|
||||
'overflow-auto',
|
||||
'overflow-y-auto',
|
||||
'scrollbar',
|
||||
|
||||
// Cursors
|
||||
'cursor-pointer',
|
||||
'cursor-not-allowed',
|
||||
|
||||
// Display
|
||||
'block',
|
||||
|
||||
// DaisyUI components
|
||||
'btn',
|
||||
'btn-xs',
|
||||
'btn-ghost',
|
||||
'btn-error',
|
||||
'input',
|
||||
'input-bordered',
|
||||
'focus:input-primary',
|
||||
'avatar',
|
||||
'placeholder',
|
||||
'loading',
|
||||
'loading-dots',
|
||||
'loading-md',
|
||||
|
||||
// Responsive variants
|
||||
'2xl:max-w-[88%]',
|
||||
'2xl:h-screen',
|
||||
|
||||
// Custom negative margins
|
||||
'-inset-3mt-2',
|
||||
]
|
||||
});
|
||||
29
packages/chat-ui/tsconfig.json
Normal file
29
packages/chat-ui/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"lib": ["ES2024", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"declaration": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
9
packages/chat-ui/tsup.config.ts
Normal file
9
packages/chat-ui/tsup.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// tsup.config.ts
|
||||
export default {
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
outDir: 'dist',
|
||||
external: ['react', 'react-dom'],
|
||||
jsx: 'react-jsx'
|
||||
}
|
||||
0
packages/google-storage/README.md
Normal file
0
packages/google-storage/README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from google.cloud import storage
|
||||
from google.cloud.storage.blob import Blob
|
||||
from google.oauth2 import service_account
|
||||
|
||||
|
||||
class GcpStorage:
|
||||
def __init__(self, credentials: str | dict | None = None):
|
||||
"""
|
||||
Initialize GCP Storage client using either:
|
||||
- A JSON string containing service account credentials
|
||||
- A dict containing service account credentials
|
||||
- None (will attempt to use default credentials)
|
||||
"""
|
||||
if credentials:
|
||||
# Convert string to dict if needed
|
||||
if isinstance(credentials, str):
|
||||
try:
|
||||
credentials_dict = json.loads(credentials)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(
|
||||
"Invalid JSON string provided for credentials"
|
||||
) from e
|
||||
else:
|
||||
credentials_dict = credentials
|
||||
|
||||
# Create credentials object from dict
|
||||
credentials_obj = service_account.Credentials.from_service_account_info(
|
||||
credentials_dict
|
||||
)
|
||||
self.client = storage.Client(credentials=credentials_obj)
|
||||
else:
|
||||
# Use default credentials if none provided
|
||||
self.client = storage.Client()
|
||||
|
||||
def _generate_signed_url(self, blob: Blob, minute_duration: int) -> str:
|
||||
expiration_time = datetime.now(UTC) + timedelta(minutes=minute_duration)
|
||||
|
||||
url = blob.generate_signed_url(
|
||||
version="v4", expiration=expiration_time, method="GET"
|
||||
)
|
||||
|
||||
return url
|
||||
|
||||
async def get_file_url(
|
||||
self, filename: str, bucket: str, minute_duration: int, image: bool
|
||||
) -> str:
|
||||
stock_url = (
|
||||
"https://t3.ftcdn.net/jpg/04/34/72/82/360_F_434728286_OWQQvAFoXZLdGHlObozsolNeuSxhpr84.jpg"
|
||||
if image
|
||||
else "https://www.banorte.com"
|
||||
)
|
||||
|
||||
if not hasattr(self, "client"):
|
||||
return stock_url
|
||||
|
||||
bucket_client = self.client.bucket(bucket)
|
||||
blob = bucket_client.blob(filename)
|
||||
|
||||
if blob.exists():
|
||||
return self._generate_signed_url(blob, minute_duration)
|
||||
else:
|
||||
return stock_url
|
||||
|
||||
async def get_blob_bytes(self, bucket: str, filename: str) -> bytes:
|
||||
if not hasattr(self, "client"):
|
||||
raise ValueError("No credentials provided to GCPStorage object.")
|
||||
|
||||
bucket_client = self.client.bucket(bucket)
|
||||
blob = bucket_client.blob(filename)
|
||||
|
||||
return blob.download_as_bytes()
|
||||
15
packages/google-storage/pyproject.toml
Normal file
15
packages/google-storage/pyproject.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[project]
|
||||
name = "google-storage"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
authors = [{ name = "ajac-zero", email = "ajcardoza2000@gmail.com" }]
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["google-cloud-storage>=2.0"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["banortegpt"]
|
||||
0
packages/mongo-memory/README.md
Normal file
0
packages/mongo-memory/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
from uuid import UUID
|
||||
|
||||
from .models import Conversation, Message
|
||||
|
||||
|
||||
async def create_conversation(
|
||||
conversation_id: UUID, system_prompt: str
|
||||
) -> Conversation:
|
||||
conversation = Conversation(
|
||||
conversation_id=conversation_id,
|
||||
messages=[Message(role="system", content=system_prompt)],
|
||||
)
|
||||
await conversation.create()
|
||||
|
||||
return conversation
|
||||
|
||||
|
||||
async def get_conversation(conversation_id: UUID) -> Conversation | None:
|
||||
conversation = await Conversation.find_one(
|
||||
Conversation.conversation_id == conversation_id
|
||||
)
|
||||
return conversation
|
||||
|
||||
|
||||
async def get_or_create_conversation(
|
||||
conversation_id: UUID, system_prompt: str
|
||||
) -> Conversation:
|
||||
conversation = await get_conversation(conversation_id)
|
||||
|
||||
if not conversation:
|
||||
conversation = await create_conversation(conversation_id, system_prompt)
|
||||
else:
|
||||
conversation.messages[0].content = system_prompt
|
||||
|
||||
return conversation
|
||||
@@ -0,0 +1,67 @@
|
||||
from typing import Annotated, Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from beanie import Document, Indexed
|
||||
from pydantic import BaseModel
|
||||
|
||||
type Role = Literal["user", "assistant", "system", "tool"]
|
||||
|
||||
|
||||
class Function(BaseModel):
|
||||
name: str
|
||||
arguments: str
|
||||
|
||||
|
||||
class Tool(BaseModel):
|
||||
id: str
|
||||
type: Literal["function"]
|
||||
function: Function
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
role: Role
|
||||
content: str | None = None
|
||||
tool_call_id: str | None = None
|
||||
tool_calls: list[Tool] | None = None
|
||||
|
||||
|
||||
class Conversation(Document):
|
||||
conversation_id: Annotated[UUID, Indexed(unique=True)]
|
||||
messages: list[Message]
|
||||
|
||||
class Settings:
|
||||
name = "conversations"
|
||||
|
||||
def to_openai_format(self, limit: int, langchain_compat: bool = False):
|
||||
history = [m.model_dump(exclude_none=True) for m in self.messages]
|
||||
|
||||
if langchain_compat:
|
||||
for msg in history:
|
||||
if "tool_calls" in msg:
|
||||
msg["additional_kwargs"] = {"tool_calls": msg.pop("tool_calls")}
|
||||
if "content" not in msg:
|
||||
msg["content"] = ""
|
||||
|
||||
if len(history) > limit:
|
||||
# Truncate the history to the last `limit` messages
|
||||
# Always keep the first message (system prompt)
|
||||
history = history[:1] + history[-limit:]
|
||||
|
||||
return history
|
||||
|
||||
def add(
|
||||
self,
|
||||
role: Role,
|
||||
*,
|
||||
content: str | None = None,
|
||||
tool_call_id: str | None = None,
|
||||
tool_calls: list[Any] | None = None,
|
||||
):
|
||||
self.messages.append(
|
||||
Message(
|
||||
role=role,
|
||||
content=content,
|
||||
tool_call_id=tool_call_id,
|
||||
tool_calls=tool_calls,
|
||||
)
|
||||
)
|
||||
15
packages/mongo-memory/pyproject.toml
Normal file
15
packages/mongo-memory/pyproject.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[project]
|
||||
name = "mongo-memory"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
authors = [{ name = "ajac-zero", email = "ajcardoza2000@gmail.com" }]
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["beanie>=1.29.0"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["banortegpt"]
|
||||
0
packages/mongo-search/README.md
Normal file
0
packages/mongo-search/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
from pymongo import AsyncMongoClient
|
||||
|
||||
|
||||
class AsyncMongo:
|
||||
def __init__(
|
||||
self, mongo_uri: str, database_name: str = "MayaContigo", mode: str = "cosmosdb"
|
||||
) -> None:
|
||||
self.mode = mode
|
||||
self.client: AsyncMongoClient = AsyncMongoClient(mongo_uri)
|
||||
self.db = self.client.get_database(database_name)
|
||||
|
||||
def build_pipeline(self, embedding, limit):
|
||||
if self.mode == "native":
|
||||
return {
|
||||
"$vectorSearch": {
|
||||
"index": "vector_index",
|
||||
"queryVector": embedding,
|
||||
"path": "embedding",
|
||||
"limit": limit,
|
||||
"numCandidates": 3 * limit,
|
||||
}
|
||||
}
|
||||
elif self.mode == "cosmosdb":
|
||||
return {
|
||||
"$search": {
|
||||
"cosmosSearch": {
|
||||
"path": "vector",
|
||||
"vector": embedding[:2000],
|
||||
"k": limit,
|
||||
}
|
||||
}
|
||||
}
|
||||
else:
|
||||
raise ValueError("Invalid mode")
|
||||
|
||||
async def semantic_search(
|
||||
self,
|
||||
collection: str,
|
||||
embedding: list[float],
|
||||
limit: int = 10,
|
||||
conditions: dict | None = None,
|
||||
threshold: float | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
db_collection = self.db[collection]
|
||||
|
||||
pipeline = self.build_pipeline(embedding, limit)
|
||||
aggregate = await db_collection.aggregate([pipeline])
|
||||
return [result async for result in aggregate]
|
||||
15
packages/mongo-search/pyproject.toml
Normal file
15
packages/mongo-search/pyproject.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[project]
|
||||
name = "mongo-search"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
authors = [{ name = "ajac-zero", email = "ajcardoza2000@gmail.com" }]
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["pymongo>=4.10.1"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["banortegpt"]
|
||||
0
packages/postgres/README.md
Normal file
0
packages/postgres/README.md
Normal file
181
packages/postgres/banortegpt/database/postgres/crud.py
Normal file
181
packages/postgres/banortegpt/database/postgres/crud.py
Normal file
@@ -0,0 +1,181 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError, NoResultFound
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .models import Assistant, Conversation, Message, User
|
||||
|
||||
### Users
|
||||
|
||||
|
||||
async def get_all_users(session: AsyncSession) -> Sequence[User]:
|
||||
statement = select(User)
|
||||
scalars = await session.scalars(statement)
|
||||
return scalars.all()
|
||||
|
||||
|
||||
async def get_user(session: AsyncSession, user_id: int) -> User:
|
||||
try:
|
||||
return await session.get_one(User, user_id)
|
||||
except NoResultFound as e:
|
||||
raise ValueError("No user by that id!") from e
|
||||
|
||||
|
||||
async def get_user_by_name(session: AsyncSession, name: str) -> User:
|
||||
try:
|
||||
statement = select(User).where(User.name == name)
|
||||
scalar = await session.scalars(statement)
|
||||
return scalar.one()
|
||||
except NoResultFound as e:
|
||||
raise ValueError("No user by that name!") from e
|
||||
|
||||
|
||||
async def create_user(session: AsyncSession, name: str) -> User:
|
||||
try:
|
||||
new_user = User(name=name)
|
||||
session.add(new_user)
|
||||
await session.commit()
|
||||
await session.refresh(new_user)
|
||||
return new_user
|
||||
except IntegrityError as e:
|
||||
raise ValueError("User by that name already exists!") from e
|
||||
|
||||
|
||||
async def delete_user(session: AsyncSession, name: str):
|
||||
try:
|
||||
statement = select(User).where(User.name == name)
|
||||
db_user: User = (await session.scalars(statement)).one()
|
||||
await session.delete(db_user)
|
||||
await session.commit()
|
||||
except NoResultFound as e:
|
||||
raise ValueError("No assistant by that id exists.") from e
|
||||
|
||||
|
||||
### Assistants
|
||||
|
||||
|
||||
async def get_all_assistants(session: AsyncSession) -> Sequence[Assistant]:
|
||||
statement = select(Assistant)
|
||||
scalars = await session.scalars(statement)
|
||||
return scalars.all()
|
||||
|
||||
|
||||
async def get_assistant(session: AsyncSession, assistant_id: int) -> Assistant:
|
||||
try:
|
||||
statement = select(Assistant).where(Assistant.id == assistant_id)
|
||||
scalars = await session.scalars(statement)
|
||||
return scalars.one()
|
||||
except NoResultFound as e:
|
||||
raise ValueError("No assistant by that id!") from e
|
||||
|
||||
|
||||
async def get_assistant_by_name(session: AsyncSession, name: str) -> Assistant:
|
||||
try:
|
||||
statement = select(Assistant).where(Assistant.name == name)
|
||||
scalars = await session.scalars(statement)
|
||||
return scalars.one()
|
||||
except NoResultFound as e:
|
||||
raise ValueError("No assistant by that name!") from e
|
||||
|
||||
|
||||
async def create_assistant(
|
||||
session: AsyncSession, name: str, system_prompt: str
|
||||
) -> Assistant:
|
||||
try:
|
||||
new_assistant = Assistant(name=name, system_prompt=system_prompt)
|
||||
session.add(new_assistant)
|
||||
await session.commit()
|
||||
await session.refresh(new_assistant)
|
||||
return new_assistant
|
||||
except IntegrityError as e:
|
||||
raise ValueError("Assistant with that name already exists.") from e
|
||||
|
||||
|
||||
async def delete_assistant(session: AsyncSession, assistant_name: str) -> None:
|
||||
try:
|
||||
statement = select(Assistant).where(Assistant.name == assistant_name)
|
||||
db_assistant: Assistant = (await session.scalars(statement)).one()
|
||||
await session.delete(db_assistant)
|
||||
await session.commit()
|
||||
except NoResultFound as e:
|
||||
raise ValueError("No assistant by that name exists.") from e
|
||||
|
||||
|
||||
### Conversations
|
||||
|
||||
|
||||
async def get_conversation(session: AsyncSession, conversation_id: int) -> Conversation:
|
||||
try:
|
||||
conversation = await session.get_one(Conversation, conversation_id)
|
||||
await session.refresh(conversation, ["messages", "id"])
|
||||
return conversation
|
||||
except NoResultFound as e:
|
||||
raise ValueError("No conversation by that id exists.") from e
|
||||
|
||||
|
||||
async def create_conversation(
|
||||
session: AsyncSession, user: str, assistant: str, system_prompt: str | None = None
|
||||
) -> Conversation:
|
||||
try:
|
||||
db_user = await get_user_by_name(session=session, name=user)
|
||||
db_assistant = await get_assistant_by_name(session=session, name=assistant)
|
||||
|
||||
await session.refresh(db_user, ["id"])
|
||||
await session.refresh(db_assistant, ["id"])
|
||||
|
||||
db_conversation = Conversation(user_id=db_user.id, assistant_id=db_assistant.id)
|
||||
|
||||
if system_prompt is not None:
|
||||
db_conversation.add_message(role="system", content=system_prompt)
|
||||
|
||||
session.add(db_conversation)
|
||||
|
||||
await session.commit()
|
||||
|
||||
return db_conversation
|
||||
except ValueError as e:
|
||||
raise ValueError("User or assistant do not exist!") from e
|
||||
|
||||
|
||||
async def delete_conversation(session: AsyncSession, conversation_id: int) -> None:
|
||||
try:
|
||||
db_conversation = await session.get_one(Conversation, conversation_id)
|
||||
await session.delete(db_conversation)
|
||||
await session.commit()
|
||||
except NoResultFound as e:
|
||||
raise ValueError("No conversation by that id exists.") from e
|
||||
|
||||
|
||||
async def soft_delete_conversation(session: AsyncSession, conversation_id: int) -> None:
|
||||
try:
|
||||
db_conversation = await session.get_one(Conversation, conversation_id)
|
||||
await session.refresh(db_conversation, ["active"])
|
||||
db_conversation.active = False
|
||||
await session.commit()
|
||||
except NoResultFound as e:
|
||||
raise ValueError("No conversation by that id exists.") from e
|
||||
|
||||
|
||||
### Messages
|
||||
|
||||
|
||||
async def get_message_by_id(session: AsyncSession, message_id: int) -> Message:
|
||||
return await session.get_one(Message, message_id)
|
||||
|
||||
|
||||
async def update_message_feedback_by_id(
|
||||
session: AsyncSession, message_id: int, rating: bool | None
|
||||
):
|
||||
try:
|
||||
db_message = await session.get_one(Message, message_id)
|
||||
db_message.feedback = rating
|
||||
|
||||
session.add(db_message)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(db_message)
|
||||
|
||||
return db_message
|
||||
except ValueError as e:
|
||||
raise ValueError("Message does not exist!") from e
|
||||
123
packages/postgres/banortegpt/database/postgres/models.py
Normal file
123
packages/postgres/banortegpt/database/postgres/models.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import ForeignKey, select
|
||||
from sqlalchemy.dialects.postgresql import JSONB, TIMESTAMP
|
||||
from sqlalchemy.ext.asyncio import AsyncAttrs
|
||||
from sqlalchemy.orm import (
|
||||
DeclarativeBase,
|
||||
Mapped,
|
||||
MappedAsDataclass,
|
||||
column_property,
|
||||
mapped_column,
|
||||
relationship,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase, MappedAsDataclass, AsyncAttrs):
|
||||
type_annotation_map = {
|
||||
datetime: TIMESTAMP(timezone=True),
|
||||
dict: JSONB,
|
||||
}
|
||||
|
||||
|
||||
class CommonMixin(MappedAsDataclass):
|
||||
id: Mapped[int] = mapped_column(init=False, primary_key=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
init=False, default_factory=lambda: datetime.now(UTC)
|
||||
)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
init=False, default=None, onupdate=lambda: datetime.now(UTC)
|
||||
)
|
||||
active: Mapped[bool] = mapped_column(init=False, default=True)
|
||||
|
||||
|
||||
class User(CommonMixin, Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
name: Mapped[str] = mapped_column(unique=True, index=True)
|
||||
|
||||
conversations: Mapped[list[Conversation]] = relationship(
|
||||
init=False, back_populates="user"
|
||||
)
|
||||
|
||||
|
||||
class Assistant(CommonMixin, Base):
|
||||
__tablename__ = "assistants"
|
||||
|
||||
name: Mapped[str] = mapped_column(unique=True, index=True)
|
||||
system_prompt: Mapped[str]
|
||||
|
||||
conversations: Mapped[list[Conversation]] = relationship(
|
||||
init=False, back_populates="assistant"
|
||||
)
|
||||
|
||||
|
||||
class Conversation(CommonMixin, Base):
|
||||
__tablename__ = "conversations"
|
||||
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
||||
assistant_id: Mapped[int] = mapped_column(ForeignKey("assistants.id"))
|
||||
|
||||
user: Mapped[User] = relationship(init=False, back_populates="conversations")
|
||||
assistant: Mapped[Assistant] = relationship(
|
||||
init=False, back_populates="conversations"
|
||||
)
|
||||
messages: Mapped[list[Message]] = relationship(
|
||||
init=False, order_by="Message.created_at"
|
||||
)
|
||||
|
||||
assistant_name = column_property(
|
||||
select(Assistant.name).where(Assistant.id == assistant_id).scalar_subquery()
|
||||
)
|
||||
|
||||
def add_message(
|
||||
self,
|
||||
role: str,
|
||||
content: str | None = None,
|
||||
tools: dict | None = None,
|
||||
query_id: int | None = None,
|
||||
):
|
||||
self.messages.append(
|
||||
Message(
|
||||
role=role,
|
||||
content=content,
|
||||
tools=tools,
|
||||
query_id=query_id,
|
||||
conversation_id=self.id,
|
||||
)
|
||||
)
|
||||
|
||||
async def to_openai_format(self):
|
||||
messages = await self.awaitable_attrs.messages
|
||||
return [(await m.to_openai_format()) for m in messages]
|
||||
|
||||
|
||||
class Message(CommonMixin, Base):
|
||||
__tablename__ = "messages"
|
||||
|
||||
conversation_id: Mapped[int] = mapped_column(ForeignKey("conversations.id"))
|
||||
|
||||
role: Mapped[str]
|
||||
content: Mapped[str | None] = mapped_column(default=None)
|
||||
feedback: Mapped[bool | None] = mapped_column(default=None)
|
||||
tools: Mapped[dict | None] = mapped_column(default=None)
|
||||
query_id: Mapped[int | None] = mapped_column(default=None)
|
||||
|
||||
async def to_openai_format(self):
|
||||
role = await self.awaitable_attrs.role
|
||||
content = await self.awaitable_attrs.content
|
||||
tools = await self.awaitable_attrs.tools
|
||||
return {
|
||||
"role": role,
|
||||
"content": content,
|
||||
**(tools or {}),
|
||||
}
|
||||
|
||||
|
||||
class Comment(CommonMixin, Base):
|
||||
__tablename__ = "comments"
|
||||
|
||||
message_id: Mapped[int] = mapped_column(ForeignKey("messages.id"))
|
||||
content: Mapped[str]
|
||||
15
packages/postgres/pyproject.toml
Normal file
15
packages/postgres/pyproject.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[project]
|
||||
name = "postgres"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
authors = [{ name = "ajac-zero", email = "ajcardoza2000@gmail.com" }]
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["sqlalchemy[asyncio,postgresql-asyncpg]>=2.0.40"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["banortegpt"]
|
||||
0
packages/qdrant/README.md
Normal file
0
packages/qdrant/README.md
Normal file
4
packages/qdrant/banortegpt/vector/qdrant/__init__.py
Normal file
4
packages/qdrant/banortegpt/vector/qdrant/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .blocking import Qdrant
|
||||
from .nonblocking import AsyncQdrant
|
||||
|
||||
__all__ = ["Qdrant", "AsyncQdrant"]
|
||||
43
packages/qdrant/banortegpt/vector/qdrant/base.py
Normal file
43
packages/qdrant/banortegpt/vector/qdrant/base.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class BaseQdrant:
|
||||
def __init__(
|
||||
self, *, url: str, api_key: str | None, collection: str | None = None
|
||||
) -> None:
|
||||
self.collection = collection
|
||||
|
||||
class Config(Protocol):
|
||||
qdrant_url: str
|
||||
qdrant_api_key: str | None
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, c: Config):
|
||||
return cls(url=c.qdrant_url, api_key=c.qdrant_api_key)
|
||||
|
||||
@classmethod
|
||||
def from_vault(
|
||||
cls,
|
||||
vault: str,
|
||||
*,
|
||||
collection: str | None = None,
|
||||
url: str | None = None,
|
||||
token: str | None = None,
|
||||
mount_point: str = "secret",
|
||||
):
|
||||
from hvac import Client
|
||||
|
||||
client = Client(url=url or "https://vault.ia-innovacion.work", token=token)
|
||||
|
||||
if not client.is_authenticated():
|
||||
raise Exception("Vault authentication failed")
|
||||
|
||||
secret_map = client.secrets.kv.v2.read_secret_version(
|
||||
path=vault, mount_point=mount_point
|
||||
)["data"]["data"]
|
||||
|
||||
return cls(
|
||||
url=secret_map["qdrant_api_url"],
|
||||
api_key=secret_map["qdrant_api_key"],
|
||||
collection=collection,
|
||||
)
|
||||
99
packages/qdrant/banortegpt/vector/qdrant/blocking.py
Normal file
99
packages/qdrant/banortegpt/vector/qdrant/blocking.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from langfuse.decorators import langfuse_context, observe
|
||||
from qdrant_client import QdrantClient, models
|
||||
|
||||
from .base import BaseQdrant
|
||||
|
||||
|
||||
class Qdrant(BaseQdrant):
|
||||
def __init__(
|
||||
self, *, url: str, api_key: str | None, collection: str | None = None
|
||||
) -> None:
|
||||
super().__init__(url=url, api_key=api_key, collection=collection)
|
||||
self.client = QdrantClient(url=url, api_key=api_key)
|
||||
|
||||
def list_collections(self) -> Sequence[str]:
|
||||
return [
|
||||
collection.name for collection in self.client.get_collections().collections
|
||||
]
|
||||
|
||||
@observe(capture_input=False)
|
||||
def semantic_search(
|
||||
self,
|
||||
embedding: Sequence[float] | models.NamedVector,
|
||||
collection: str | None = None,
|
||||
limit: int = 10,
|
||||
conditions: Any | None = None,
|
||||
threshold: float | None = None,
|
||||
**kwargs,
|
||||
) -> Sequence[dict[str, Any]]:
|
||||
if collection is None:
|
||||
if self.collection is None:
|
||||
raise ValueError(
|
||||
"No collection set; Please set a collection before calling 'semantic_search'"
|
||||
)
|
||||
collection = self.collection
|
||||
|
||||
langfuse_context.update_current_observation(
|
||||
input={
|
||||
"collection": collection,
|
||||
"limit": limit,
|
||||
"embedding": embedding,
|
||||
"conditions": conditions,
|
||||
}
|
||||
)
|
||||
|
||||
points = self.client.search(
|
||||
collection_name=collection,
|
||||
query_vector=embedding,
|
||||
query_filter=conditions,
|
||||
limit=limit,
|
||||
with_payload=True,
|
||||
with_vectors=False,
|
||||
score_threshold=threshold,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return [point.payload for point in points if point.payload is not None]
|
||||
|
||||
def create_collection_if_not_exists(
|
||||
self,
|
||||
*,
|
||||
collection: str | None = None,
|
||||
vector_config: dict[str, models.VectorParams],
|
||||
):
|
||||
if collection is None:
|
||||
if self.collection is None:
|
||||
raise ValueError(
|
||||
"No collection is set; Please set a collection before calling 'create_collection_if_not_exists'"
|
||||
)
|
||||
collection = self.collection
|
||||
|
||||
result = self.client.get_collections()
|
||||
collection_names = [collection.name for collection in result.collections]
|
||||
|
||||
if collection not in collection_names:
|
||||
return self.client.create_collection(
|
||||
collection_name=collection,
|
||||
vectors_config=vector_config,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
def upload_to_collection(
|
||||
self,
|
||||
*,
|
||||
points: list[models.PointStruct],
|
||||
collection: str | None = None,
|
||||
):
|
||||
if collection is None:
|
||||
if self.collection is None:
|
||||
raise ValueError(
|
||||
"No collection is set; Please set a collection before calling 'create_collection_if_not_exists'"
|
||||
)
|
||||
collection = self.collection
|
||||
|
||||
for point in points:
|
||||
self.client.upsert(collection_name=collection, points=[point], wait=True)
|
||||
96
packages/qdrant/banortegpt/vector/qdrant/nonblocking.py
Normal file
96
packages/qdrant/banortegpt/vector/qdrant/nonblocking.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from langfuse.decorators import langfuse_context, observe
|
||||
from qdrant_client import AsyncQdrantClient, models
|
||||
|
||||
from .base import BaseQdrant
|
||||
|
||||
|
||||
class AsyncQdrant(BaseQdrant):
|
||||
def __init__(
|
||||
self, *, url: str, api_key: str | None, collection: str | None = None
|
||||
) -> None:
|
||||
super().__init__(url=url, api_key=api_key, collection=collection)
|
||||
self.client = AsyncQdrantClient(url=url, api_key=api_key)
|
||||
|
||||
@observe(capture_input=False)
|
||||
async def semantic_search(
|
||||
self,
|
||||
embedding: Sequence[float] | models.NamedVector,
|
||||
collection: str | None = None,
|
||||
limit: int = 10,
|
||||
conditions: Any | None = None,
|
||||
threshold: float | None = None,
|
||||
**kwargs,
|
||||
) -> Sequence[dict[str, Any]]:
|
||||
if collection is None:
|
||||
if self.collection is None:
|
||||
raise ValueError(
|
||||
"No collection set; Please set a collection before calling 'semantic_search'"
|
||||
)
|
||||
collection = self.collection
|
||||
|
||||
langfuse_context.update_current_observation(
|
||||
input={
|
||||
"collection": collection,
|
||||
"limit": limit,
|
||||
"embedding": embedding,
|
||||
"conditions": conditions,
|
||||
}
|
||||
)
|
||||
|
||||
points = await self.client.search(
|
||||
collection_name=collection,
|
||||
query_vector=embedding,
|
||||
query_filter=conditions,
|
||||
limit=limit,
|
||||
with_payload=True,
|
||||
with_vectors=False,
|
||||
score_threshold=threshold,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return [point.payload for point in points if point.payload is not None]
|
||||
|
||||
async def create_collection_if_not_exists(
|
||||
self,
|
||||
*,
|
||||
collection: str | None = None,
|
||||
vector_config: dict[str, models.VectorParams],
|
||||
):
|
||||
if collection is None:
|
||||
if self.collection is None:
|
||||
raise ValueError(
|
||||
"No collection is set; Please set a collection before calling 'create_collection_if_not_exists'"
|
||||
)
|
||||
collection = self.collection
|
||||
|
||||
result = await self.client.get_collections()
|
||||
collection_names = [collection.name for collection in result.collections]
|
||||
|
||||
if collection not in collection_names:
|
||||
return await self.client.create_collection(
|
||||
collection_name=collection,
|
||||
vectors_config=vector_config,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
async def upload_to_collection(
|
||||
self,
|
||||
*,
|
||||
points: list[models.PointStruct],
|
||||
collection: str | None = None,
|
||||
):
|
||||
if collection is None:
|
||||
if self.collection is None:
|
||||
raise ValueError(
|
||||
"No collection is set; Please set a collection before calling 'create_collection_if_not_exists'"
|
||||
)
|
||||
collection = self.collection
|
||||
|
||||
for point in points:
|
||||
await self.client.upsert(
|
||||
collection_name=collection, points=[point], wait=True
|
||||
)
|
||||
0
packages/qdrant/banortegpt/vector/qdrant/py.typed
Normal file
0
packages/qdrant/banortegpt/vector/qdrant/py.typed
Normal file
19
packages/qdrant/pyproject.toml
Normal file
19
packages/qdrant/pyproject.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[project]
|
||||
name = "qdrant"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
authors = [{ name = "ajac-zero", email = "ajcardoza2000@gmail.com" }]
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["langfuse>=2.60.2", "qdrant-client>=1.12.2"]
|
||||
|
||||
[tool.pyright]
|
||||
venvPath = "."
|
||||
venv = ".venv"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["banortegpt"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
0
packages/vertex-ai-gemini/README.md
Normal file
0
packages/vertex-ai-gemini/README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import google.oauth2.service_account as sa
|
||||
import vertexai
|
||||
import vertexai.generative_models as gm
|
||||
from PIL.Image import Image
|
||||
|
||||
|
||||
class Gemini:
|
||||
def __init__(
|
||||
self, model: str | None = None, *, account_info: dict[str, str] | None = None
|
||||
) -> None:
|
||||
if account_info is None:
|
||||
account_info = json.loads(os.environ["GCP_SERVICE_ACCOUNT"])
|
||||
assert account_info is not None
|
||||
|
||||
credentials = sa.Credentials.from_service_account_info(account_info)
|
||||
vertexai.init(project=account_info["project_id"], credentials=credentials)
|
||||
|
||||
self.model = gm.GenerativeModel(model) if model else None
|
||||
|
||||
def set_model(self, model: str):
|
||||
self.model = gm.GenerativeModel(model)
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def from_path(cls, model: str, path: Path):
|
||||
account_info = json.loads(path.read_text())
|
||||
return cls(model, account_info=account_info)
|
||||
|
||||
def generate(self, contents, response_schema=None):
|
||||
if self.model is None:
|
||||
raise ValueError(
|
||||
"No model set; Please choose a model before calling 'generate'"
|
||||
)
|
||||
|
||||
generation_config = None
|
||||
if response_schema:
|
||||
generation_config = gm.GenerationConfig(
|
||||
response_mime_type="application/json", response_schema=response_schema
|
||||
)
|
||||
return self.model.generate_content(
|
||||
contents, generation_config=generation_config
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_part_from_pdf_bytes(pdf_bytes: bytes):
|
||||
part = gm.Part.from_data(
|
||||
data=pdf_bytes,
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
return part
|
||||
|
||||
@staticmethod
|
||||
def create_part_from_PIL_image(pil_image: Image, format="jpeg"):
|
||||
with io.BytesIO() as img_buffer:
|
||||
pil_image.save(img_buffer, format=format.upper())
|
||||
img_bytes = img_buffer.getvalue()
|
||||
|
||||
part = gm.Part.from_data(
|
||||
data=img_bytes,
|
||||
mime_type="image/" + format,
|
||||
)
|
||||
|
||||
return part
|
||||
|
||||
@classmethod
|
||||
def from_vault(
|
||||
cls,
|
||||
vault: str,
|
||||
*,
|
||||
model: str | None = None,
|
||||
url: str | None = None,
|
||||
token: str | None = None,
|
||||
mount_point: str = "secret",
|
||||
):
|
||||
from hvac import Client
|
||||
|
||||
client = Client(url=url or "https://vault.ia-innovacion.work", token=token)
|
||||
|
||||
if not client.is_authenticated():
|
||||
raise Exception("Vault authentication failed")
|
||||
|
||||
secret_map = client.secrets.kv.v2.read_secret_version(
|
||||
path=vault, mount_point=mount_point
|
||||
)["data"]["data"]
|
||||
|
||||
return cls(
|
||||
account_info=secret_map["gcp_service_account"],
|
||||
model=model,
|
||||
)
|
||||
18
packages/vertex-ai-gemini/pyproject.toml
Normal file
18
packages/vertex-ai-gemini/pyproject.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[project]
|
||||
name = "vertex-ai-gemini"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"google-auth>=2.40.1",
|
||||
"google-cloud-aiplatform>=1.92.0",
|
||||
"pillow>=11.2.1",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["banortegpt"]
|
||||
Reference in New Issue
Block a user