appkit-assistant 0.7.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- appkit_assistant/backend/model_manager.py +133 -0
- appkit_assistant/backend/models.py +103 -0
- appkit_assistant/backend/processor.py +46 -0
- appkit_assistant/backend/processors/ai_models.py +109 -0
- appkit_assistant/backend/processors/knowledgeai_processor.py +275 -0
- appkit_assistant/backend/processors/lorem_ipsum_processor.py +123 -0
- appkit_assistant/backend/processors/openai_base.py +73 -0
- appkit_assistant/backend/processors/openai_chat_completion_processor.py +117 -0
- appkit_assistant/backend/processors/openai_responses_processor.py +508 -0
- appkit_assistant/backend/processors/perplexity_processor.py +118 -0
- appkit_assistant/backend/repositories.py +96 -0
- appkit_assistant/backend/system_prompt.py +56 -0
- appkit_assistant/components/__init__.py +38 -0
- appkit_assistant/components/composer.py +154 -0
- appkit_assistant/components/composer_key_handler.py +38 -0
- appkit_assistant/components/mcp_server_dialogs.py +344 -0
- appkit_assistant/components/mcp_server_table.py +76 -0
- appkit_assistant/components/message.py +299 -0
- appkit_assistant/components/thread.py +252 -0
- appkit_assistant/components/threadlist.py +134 -0
- appkit_assistant/components/tools_modal.py +97 -0
- appkit_assistant/configuration.py +10 -0
- appkit_assistant/state/mcp_server_state.py +222 -0
- appkit_assistant/state/thread_state.py +874 -0
- appkit_assistant-0.7.1.dist-info/METADATA +8 -0
- appkit_assistant-0.7.1.dist-info/RECORD +27 -0
- appkit_assistant-0.7.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from appkit_assistant.backend.models import AIModel
|
|
6
|
+
from appkit_assistant.backend.processor import Processor
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ModelManager:
|
|
12
|
+
"""Singleton service manager for AI processing services."""
|
|
13
|
+
|
|
14
|
+
_instance: Optional["ModelManager"] = None
|
|
15
|
+
_lock = threading.Lock()
|
|
16
|
+
_default_model_id = (
|
|
17
|
+
None # Default model ID will be set to the first registered model
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def __new__(cls) -> "ModelManager":
|
|
21
|
+
if cls._instance is None:
|
|
22
|
+
with cls._lock:
|
|
23
|
+
if cls._instance is None:
|
|
24
|
+
cls._instance = super(ModelManager, cls).__new__(cls) # noqa UP008
|
|
25
|
+
return cls._instance
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
"""Initialize the service manager if not already initialized."""
|
|
29
|
+
if not hasattr(self, "_initialized"):
|
|
30
|
+
self._processors: dict[str, Processor] = {}
|
|
31
|
+
self._models: dict[str, AIModel] = {}
|
|
32
|
+
self._model_to_processor: dict[str, str] = {}
|
|
33
|
+
self._initialized = True
|
|
34
|
+
logger.debug("ModelManager initialized")
|
|
35
|
+
|
|
36
|
+
def register_processor(self, processor_name: str, processor: Processor) -> None:
|
|
37
|
+
"""
|
|
38
|
+
Register a processor with the service manager.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
processor_name: Name of the processor.
|
|
42
|
+
processor: Instance of a Processor.
|
|
43
|
+
"""
|
|
44
|
+
self._processors[processor_name] = processor
|
|
45
|
+
|
|
46
|
+
# Extract and register all models supported by this processor
|
|
47
|
+
supported_models = processor.get_supported_models()
|
|
48
|
+
for model_id, model in supported_models.items():
|
|
49
|
+
if model_id not in self._models:
|
|
50
|
+
self._models[model_id] = model
|
|
51
|
+
self._model_to_processor[model_id] = processor_name
|
|
52
|
+
|
|
53
|
+
# Set the first registered model as default if no default is set
|
|
54
|
+
if self._default_model_id is None:
|
|
55
|
+
self._default_model_id = model_id
|
|
56
|
+
logger.debug("Set first model %s as default", model_id)
|
|
57
|
+
|
|
58
|
+
logger.debug("Registered processor: %s", processor_name)
|
|
59
|
+
|
|
60
|
+
def get_processor_for_model(self, model_id: str) -> Processor | None:
|
|
61
|
+
"""
|
|
62
|
+
Get the processor that supports the specified model.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
model_id: ID of the model.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The processor that supports the model or None if no processor is found.
|
|
69
|
+
"""
|
|
70
|
+
processor_name = self._model_to_processor.get(model_id)
|
|
71
|
+
if processor_name:
|
|
72
|
+
return self._processors.get(processor_name)
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def get_all_models(self) -> list[AIModel]:
|
|
76
|
+
"""
|
|
77
|
+
Get all registered models.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
List of all models.
|
|
81
|
+
"""
|
|
82
|
+
return sorted(
|
|
83
|
+
self._models.values(),
|
|
84
|
+
key=lambda model: (
|
|
85
|
+
model.icon.lower() if model.icon else "",
|
|
86
|
+
model.text.lower(),
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def get_model(self, model_id: str) -> AIModel | None:
|
|
91
|
+
"""
|
|
92
|
+
Get a model by its ID.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
model_id: ID of the model.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The model or None if not found.
|
|
99
|
+
"""
|
|
100
|
+
return self._models.get(model_id)
|
|
101
|
+
|
|
102
|
+
def get_default_model(self) -> str:
|
|
103
|
+
"""
|
|
104
|
+
Get the default model ID.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The default model ID as a string.
|
|
108
|
+
"""
|
|
109
|
+
if self._default_model_id is None:
|
|
110
|
+
if self._models:
|
|
111
|
+
self._default_model_id = next(iter(self._models.keys()))
|
|
112
|
+
logger.debug(
|
|
113
|
+
"Using first available model %s as default", self._default_model_id
|
|
114
|
+
)
|
|
115
|
+
else:
|
|
116
|
+
logger.warning("No models registered, returning fallback model name")
|
|
117
|
+
return "default"
|
|
118
|
+
return self._default_model_id
|
|
119
|
+
|
|
120
|
+
def set_default_model(self, model_id: str) -> None:
|
|
121
|
+
"""
|
|
122
|
+
Set the default model ID.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
model_id: ID of the model to set as default.
|
|
126
|
+
"""
|
|
127
|
+
if model_id in self._models:
|
|
128
|
+
self._default_model_id = model_id
|
|
129
|
+
logger.debug("Default model set to: %s", model_id)
|
|
130
|
+
else:
|
|
131
|
+
logger.warning(
|
|
132
|
+
"Attempted to set unregistered model %s as default. Ignoring.", model_id
|
|
133
|
+
)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
import reflex as rx
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from sqlmodel import Field
|
|
6
|
+
|
|
7
|
+
from appkit_commons.database.entities import EncryptedString
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ChunkType(StrEnum):
|
|
11
|
+
"""Enum for chunk types."""
|
|
12
|
+
|
|
13
|
+
TEXT = "text" # default
|
|
14
|
+
ANNOTATION = "annotation" # for text annotations
|
|
15
|
+
IMAGE = "image"
|
|
16
|
+
IMAGE_PARTIAL = "image_partial" # for streaming image generation
|
|
17
|
+
THINKING = "thinking" # when the model is "thinking" / reasoning
|
|
18
|
+
THINKING_RESULT = "thinking_result" # when the "thinking" is done
|
|
19
|
+
ACTION = "action" # when the user needs to take action
|
|
20
|
+
TOOL_RESULT = "tool_result" # result from a tool
|
|
21
|
+
TOOL_CALL = "tool_call" # calling a tool
|
|
22
|
+
COMPLETION = "completion" # when response generation is complete
|
|
23
|
+
ERROR = "error" # when an error occurs
|
|
24
|
+
LIFECYCLE = "lifecycle"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Chunk(BaseModel):
|
|
28
|
+
"""Model for text chunks."""
|
|
29
|
+
|
|
30
|
+
type: ChunkType
|
|
31
|
+
text: str
|
|
32
|
+
chunk_metadata: dict[str, str] = {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ThreadStatus(StrEnum):
|
|
36
|
+
"""Enum for thread status."""
|
|
37
|
+
|
|
38
|
+
NEW = "new"
|
|
39
|
+
ACTIVE = "active"
|
|
40
|
+
IDLE = "idle"
|
|
41
|
+
WAITING = "waiting"
|
|
42
|
+
DELETED = "deleted"
|
|
43
|
+
ARCHIVED = "archived"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MessageType(StrEnum):
|
|
47
|
+
"""Enum for message types."""
|
|
48
|
+
|
|
49
|
+
HUMAN = "human"
|
|
50
|
+
SYSTEM = "system"
|
|
51
|
+
ASSISTANT = "assistant"
|
|
52
|
+
TOOL_USE = "tool_use"
|
|
53
|
+
ERROR = "error"
|
|
54
|
+
INFO = "info"
|
|
55
|
+
WARNING = "warning"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Message(BaseModel):
|
|
59
|
+
text: str
|
|
60
|
+
editable: bool = False
|
|
61
|
+
type: MessageType
|
|
62
|
+
done: bool = False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AIModel(BaseModel):
|
|
66
|
+
id: str
|
|
67
|
+
text: str
|
|
68
|
+
icon: str = "codesandbox"
|
|
69
|
+
stream: bool = False
|
|
70
|
+
tenant_key: str = ""
|
|
71
|
+
project_id: int = 0
|
|
72
|
+
model: str = "default"
|
|
73
|
+
temperature: float = 0.05
|
|
74
|
+
supports_tools: bool = False
|
|
75
|
+
supports_attachments: bool = False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Suggestion(BaseModel):
|
|
79
|
+
prompt: str
|
|
80
|
+
icon: str = ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ThreadModel(BaseModel):
|
|
84
|
+
thread_id: str
|
|
85
|
+
title: str = ""
|
|
86
|
+
active: bool = False
|
|
87
|
+
state: ThreadStatus = ThreadStatus.NEW
|
|
88
|
+
prompt: str | None = ""
|
|
89
|
+
messages: list[Message] = []
|
|
90
|
+
ai_model: str = ""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class MCPServer(rx.Model, table=True):
|
|
94
|
+
"""Model for MCP (Model Context Protocol) server configuration."""
|
|
95
|
+
|
|
96
|
+
__tablename__ = "mcp_server"
|
|
97
|
+
|
|
98
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
99
|
+
name: str = Field(unique=True, max_length=100, nullable=False)
|
|
100
|
+
description: str = Field(default="", max_length=255, nullable=True)
|
|
101
|
+
url: str = Field(nullable=False)
|
|
102
|
+
headers: str = Field(nullable=False, sa_type=EncryptedString)
|
|
103
|
+
prompt: str = Field(default="", max_length=2000, nullable=True)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base processor interface for AI processing services.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import abc
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import AsyncGenerator
|
|
8
|
+
|
|
9
|
+
from appkit_assistant.backend.models import AIModel, Chunk, MCPServer, Message
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Processor(abc.ABC):
|
|
15
|
+
"""Base processor interface for AI processing services."""
|
|
16
|
+
|
|
17
|
+
@abc.abstractmethod
|
|
18
|
+
async def process(
|
|
19
|
+
self,
|
|
20
|
+
messages: list[Message],
|
|
21
|
+
model_id: str,
|
|
22
|
+
files: list[str] | None = None,
|
|
23
|
+
mcp_servers: list[MCPServer] | None = None,
|
|
24
|
+
) -> AsyncGenerator[Chunk, None]:
|
|
25
|
+
"""
|
|
26
|
+
Process the thread using an AI model.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
messages: The list of messages to process.
|
|
30
|
+
model_id: The ID of the model to use.
|
|
31
|
+
files: Optional list of file paths that were uploaded.
|
|
32
|
+
mcp_servers: Optional list of MCP servers to use as tools.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
An async generator that yields Chunk objects containing different content
|
|
36
|
+
types.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
@abc.abstractmethod
|
|
40
|
+
def get_supported_models(self) -> dict[str, AIModel]:
|
|
41
|
+
"""
|
|
42
|
+
Get a dictionary of models supported by this processor.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Dictionary mapping model IDs to AIModel objects.
|
|
46
|
+
"""
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from typing import Final
|
|
2
|
+
|
|
3
|
+
from appkit_assistant.backend.models import AIModel
|
|
4
|
+
|
|
5
|
+
DEFAULT: Final = AIModel(
|
|
6
|
+
id="default",
|
|
7
|
+
text="Default (GPT 4.1 Mini)",
|
|
8
|
+
icon="avvia_intelligence",
|
|
9
|
+
model="default",
|
|
10
|
+
stream=True,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
GEMINI_2_5_FLASH: Final = AIModel(
|
|
14
|
+
id="gemini-2-5-flash",
|
|
15
|
+
text="Gemini 2.5 Flash",
|
|
16
|
+
icon="googlegemini",
|
|
17
|
+
model="gemini-2-5-flash",
|
|
18
|
+
)
|
|
19
|
+
LLAMA_3_2_VISION: Final = AIModel(
|
|
20
|
+
id="llama32_vision_90b",
|
|
21
|
+
text="Llama 3.2 Vision 90B (OnPrem)",
|
|
22
|
+
icon="ollama",
|
|
23
|
+
model="lllama32_vision_90b",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
GPT_4o: Final = AIModel(
|
|
27
|
+
id="gpt-4o",
|
|
28
|
+
text="GPT 4o",
|
|
29
|
+
icon="openai",
|
|
30
|
+
model="gpt-4o",
|
|
31
|
+
stream=True,
|
|
32
|
+
supports_attachments=True,
|
|
33
|
+
supports_tools=True,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
GPT_4_1: Final = AIModel(
|
|
37
|
+
id="gpt-4.1",
|
|
38
|
+
text="GPT-4.1",
|
|
39
|
+
icon="openai",
|
|
40
|
+
model="gpt-4.1",
|
|
41
|
+
stream=True,
|
|
42
|
+
supports_attachments=True,
|
|
43
|
+
supports_tools=True,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
O3: Final = AIModel(
|
|
47
|
+
id="o3",
|
|
48
|
+
text="o3 Reasoning",
|
|
49
|
+
icon="openai",
|
|
50
|
+
model="o3",
|
|
51
|
+
temperature=1,
|
|
52
|
+
stream=True,
|
|
53
|
+
supports_attachments=True,
|
|
54
|
+
supports_tools=True,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
O4_MINI: Final = AIModel(
|
|
58
|
+
id="o4-mini",
|
|
59
|
+
text="o4 Mini Reasoning",
|
|
60
|
+
icon="openai",
|
|
61
|
+
model="o4-mini",
|
|
62
|
+
stream=True,
|
|
63
|
+
supports_attachments=True,
|
|
64
|
+
supports_tools=True,
|
|
65
|
+
temperature=1,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
GPT_5: Final = AIModel(
|
|
69
|
+
id="gpt-5",
|
|
70
|
+
text="GPT 5",
|
|
71
|
+
icon="openai",
|
|
72
|
+
model="gpt-5",
|
|
73
|
+
stream=True,
|
|
74
|
+
supports_attachments=True,
|
|
75
|
+
supports_tools=True,
|
|
76
|
+
temperature=1,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
GPT_5_CHAT: Final = AIModel(
|
|
80
|
+
id="gpt-5-chat",
|
|
81
|
+
text="GPT 5 Chat",
|
|
82
|
+
icon="openai",
|
|
83
|
+
model="gpt-5-chat",
|
|
84
|
+
stream=True,
|
|
85
|
+
supports_attachments=True,
|
|
86
|
+
supports_tools=False,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
GPT_5_MINI: Final = AIModel(
|
|
90
|
+
id="gpt-5-mini",
|
|
91
|
+
text="GPT 5 Mini",
|
|
92
|
+
icon="openai",
|
|
93
|
+
model="gpt-5-mini",
|
|
94
|
+
stream=True,
|
|
95
|
+
supports_attachments=True,
|
|
96
|
+
supports_tools=True,
|
|
97
|
+
temperature=1,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
GPT_5_NANO: Final = AIModel(
|
|
101
|
+
id="gpt-5-nano",
|
|
102
|
+
text="GPT 5 Nano",
|
|
103
|
+
icon="openai",
|
|
104
|
+
model="gpt-5-nano",
|
|
105
|
+
stream=True,
|
|
106
|
+
supports_attachments=True,
|
|
107
|
+
supports_tools=True,
|
|
108
|
+
temperature=1,
|
|
109
|
+
)
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from openai import AsyncOpenAI, AsyncStream
|
|
7
|
+
from openai.types.chat import ChatCompletionMessageParam
|
|
8
|
+
|
|
9
|
+
from appkit_assistant.backend.models import (
|
|
10
|
+
AIModel,
|
|
11
|
+
Chunk,
|
|
12
|
+
ChunkType,
|
|
13
|
+
MCPServer,
|
|
14
|
+
Message,
|
|
15
|
+
MessageType,
|
|
16
|
+
)
|
|
17
|
+
from appkit_assistant.backend.processor import Processor
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class KnowledgeAIProcessor(Processor):
|
|
23
|
+
"""Processor that generates Knowledge AI text responses."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
server: str,
|
|
28
|
+
api_key: str,
|
|
29
|
+
models: dict[str, AIModel] | None = None,
|
|
30
|
+
with_projects: bool = False,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Initialize the Knowledge AI processor."""
|
|
33
|
+
super().__init__()
|
|
34
|
+
self.api_key = api_key
|
|
35
|
+
self.server = server
|
|
36
|
+
self.models = models
|
|
37
|
+
self.with_projects = with_projects
|
|
38
|
+
|
|
39
|
+
if with_projects:
|
|
40
|
+
self._initialize_models()
|
|
41
|
+
|
|
42
|
+
def _initialize_models(self) -> None:
|
|
43
|
+
"""Initialize the models supported by this processor."""
|
|
44
|
+
try:
|
|
45
|
+
from knai_avvia.backend.models import Project # noqa: PLC0415
|
|
46
|
+
from knai_avvia.backend.project_repository import ( # noqa: PLC0415
|
|
47
|
+
load_projects, # noqa: E402
|
|
48
|
+
)
|
|
49
|
+
except ImportError as e:
|
|
50
|
+
logger.error("knai_avvia package not available: %s", e)
|
|
51
|
+
self.models = {}
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
projects: list[Project] = asyncio.run(
|
|
56
|
+
load_projects(
|
|
57
|
+
url=self.server,
|
|
58
|
+
api_key=self.api_key,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if self.models is None:
|
|
63
|
+
self.models = {}
|
|
64
|
+
|
|
65
|
+
for project in projects:
|
|
66
|
+
project_key = f"{project.id}"
|
|
67
|
+
self.models[project_key] = AIModel(
|
|
68
|
+
id=project_key,
|
|
69
|
+
text=project.name,
|
|
70
|
+
icon="avvia_intelligence",
|
|
71
|
+
)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.error("Failed to load projects from Knowledge AI: %s", e)
|
|
74
|
+
self.models = {}
|
|
75
|
+
|
|
76
|
+
async def process(
|
|
77
|
+
self,
|
|
78
|
+
messages: list[Message],
|
|
79
|
+
model_id: str,
|
|
80
|
+
files: list[str] | None = None, # noqa: ARG002
|
|
81
|
+
mcp_servers: list[MCPServer] | None = None, # noqa: ARG002
|
|
82
|
+
) -> AsyncGenerator[Chunk, None]:
|
|
83
|
+
try:
|
|
84
|
+
from knai_avvia.backend.chat_client import chat_completion # noqa: PLC0415
|
|
85
|
+
except ImportError as e:
|
|
86
|
+
logger.error("knai_avvia package not available: %s", e)
|
|
87
|
+
raise ImportError(
|
|
88
|
+
"knai_avvia package is required for KnowledgeAIProcessor"
|
|
89
|
+
) from e
|
|
90
|
+
|
|
91
|
+
if model_id not in self.models:
|
|
92
|
+
logger.error("Model %s not supported by OpenAI processor", model_id)
|
|
93
|
+
raise ValueError(f"Model {model_id} not supported by OpenAI processor")
|
|
94
|
+
|
|
95
|
+
chat_messages = self._convert_messages(messages)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
result = await chat_completion(
|
|
99
|
+
api_key=self.api_key,
|
|
100
|
+
server=self.server,
|
|
101
|
+
project_id=int(model_id),
|
|
102
|
+
question=messages[-2].text, # last human message
|
|
103
|
+
history=chat_messages,
|
|
104
|
+
temperature=0.05,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if result.answer:
|
|
108
|
+
yield Chunk(
|
|
109
|
+
type=ChunkType.TEXT,
|
|
110
|
+
text=result.answer,
|
|
111
|
+
chunk_metadata={
|
|
112
|
+
"source": "knowledgeai",
|
|
113
|
+
"project_id": model_id,
|
|
114
|
+
"streaming": str(False),
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
except Exception as e:
|
|
118
|
+
raise e
|
|
119
|
+
|
|
120
|
+
def get_supported_models(self) -> dict[str, AIModel]:
|
|
121
|
+
return self.models if self.api_key else {}
|
|
122
|
+
|
|
123
|
+
def _convert_messages(self, messages: list[Message]) -> list[dict[str, str]]:
|
|
124
|
+
return [
|
|
125
|
+
{"role": "Human", "message": msg.text}
|
|
126
|
+
if msg.type == MessageType.HUMAN
|
|
127
|
+
else {"role": "AI", "message": msg.text}
|
|
128
|
+
for msg in (messages or [])
|
|
129
|
+
if msg.type in (MessageType.HUMAN, MessageType.ASSISTANT)
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class KnowledgeAIOpenAIProcessor(Processor):
|
|
134
|
+
"""Processor that generates Knowledge AI text responses."""
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
server: str,
|
|
139
|
+
api_key: str,
|
|
140
|
+
models: dict[str, AIModel] | None = None,
|
|
141
|
+
with_projects: bool = False,
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Initialize the Knowledge AI processor."""
|
|
144
|
+
self.api_key = api_key
|
|
145
|
+
self.server = server
|
|
146
|
+
self.models = models
|
|
147
|
+
self.with_projects = with_projects
|
|
148
|
+
self.client = (
|
|
149
|
+
AsyncOpenAI(api_key=self.api_key, base_url=self.server + "/api/openai/v1")
|
|
150
|
+
if self.api_key
|
|
151
|
+
else None
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if self.with_projects:
|
|
155
|
+
self._initialize_models()
|
|
156
|
+
|
|
157
|
+
def _initialize_models(self) -> None:
|
|
158
|
+
"""Initialize the models supported by this processor."""
|
|
159
|
+
try:
|
|
160
|
+
from knai_avvia.backend.models import Project # noqa: PLC0415
|
|
161
|
+
from knai_avvia.backend.project_repository import ( # noqa: PLC0415
|
|
162
|
+
load_projects, # noqa: E402
|
|
163
|
+
)
|
|
164
|
+
except ImportError as e:
|
|
165
|
+
logger.error("knai_avvia package not available: %s", e)
|
|
166
|
+
self.models = {}
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
projects: list[Project] = asyncio.run(
|
|
171
|
+
load_projects(
|
|
172
|
+
url=self.server,
|
|
173
|
+
api_key=self.api_key,
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if self.models is None:
|
|
178
|
+
self.models = {}
|
|
179
|
+
|
|
180
|
+
for project in projects:
|
|
181
|
+
project_key = f"{project.id}"
|
|
182
|
+
self.models[project_key] = AIModel(
|
|
183
|
+
id=project_key,
|
|
184
|
+
project_id=project.id,
|
|
185
|
+
text=project.name,
|
|
186
|
+
icon="avvia_intelligence",
|
|
187
|
+
stream=False,
|
|
188
|
+
)
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.error("Failed to load projects from Knowledge AI: %s", e)
|
|
191
|
+
self.models = {}
|
|
192
|
+
|
|
193
|
+
async def process(
|
|
194
|
+
self,
|
|
195
|
+
messages: list[Message],
|
|
196
|
+
model_id: str,
|
|
197
|
+
files: list[str] | None = None, # noqa: ARG002
|
|
198
|
+
mcp_servers: list[MCPServer] | None = None, # noqa: ARG002
|
|
199
|
+
) -> AsyncGenerator[Chunk, None]:
|
|
200
|
+
if not self.client:
|
|
201
|
+
raise ValueError("KnowledgeAI OpenAI Client not initialized.")
|
|
202
|
+
|
|
203
|
+
model = self.models.get(model_id)
|
|
204
|
+
if not model:
|
|
205
|
+
raise ValueError(
|
|
206
|
+
"Model %s not supported by KnowledgeAI processor", model_id
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
chat_messages = self._convert_messages_to_openai_format(messages)
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
session_params: dict[str, Any] = {
|
|
213
|
+
"model": model.model if model.project_id else model.id,
|
|
214
|
+
"messages": chat_messages[:-1],
|
|
215
|
+
"stream": model.stream,
|
|
216
|
+
}
|
|
217
|
+
if model.project_id:
|
|
218
|
+
session_params["user"] = str(model.project_id)
|
|
219
|
+
|
|
220
|
+
session = await self.client.chat.completions.create(**session_params)
|
|
221
|
+
|
|
222
|
+
if isinstance(session, AsyncStream):
|
|
223
|
+
async for event in session:
|
|
224
|
+
if event.choices and event.choices[0].delta:
|
|
225
|
+
content = event.choices[0].delta.content
|
|
226
|
+
if content:
|
|
227
|
+
yield Chunk(
|
|
228
|
+
type=ChunkType.TEXT,
|
|
229
|
+
text=content,
|
|
230
|
+
chunk_metadata={
|
|
231
|
+
"source": "knowledgeai_openai",
|
|
232
|
+
"streaming": str(True),
|
|
233
|
+
"model_id": model_id,
|
|
234
|
+
},
|
|
235
|
+
)
|
|
236
|
+
elif session.choices and session.choices[0].message:
|
|
237
|
+
content = session.choices[0].message.content
|
|
238
|
+
if content:
|
|
239
|
+
logger.debug("Content:\n%s", content)
|
|
240
|
+
yield Chunk(
|
|
241
|
+
type=ChunkType.TEXT,
|
|
242
|
+
text=content,
|
|
243
|
+
chunk_metadata={
|
|
244
|
+
"source": "knowledgeai_openai",
|
|
245
|
+
"streaming": str(False),
|
|
246
|
+
"model_id": model_id,
|
|
247
|
+
},
|
|
248
|
+
)
|
|
249
|
+
except Exception as e:
|
|
250
|
+
logger.exception("Failed to get response from OpenAI: %s", e)
|
|
251
|
+
raise e
|
|
252
|
+
|
|
253
|
+
def get_supported_models(self) -> dict[str, AIModel]:
|
|
254
|
+
return self.models if self.api_key else {}
|
|
255
|
+
|
|
256
|
+
def _convert_messages_to_openai_format(
|
|
257
|
+
self, messages: list[Message]
|
|
258
|
+
) -> list[ChatCompletionMessageParam]:
|
|
259
|
+
formatted: list[ChatCompletionMessageParam] = []
|
|
260
|
+
role_map = {
|
|
261
|
+
MessageType.HUMAN: "user",
|
|
262
|
+
MessageType.SYSTEM: "system",
|
|
263
|
+
MessageType.ASSISTANT: "assistant",
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
for msg in messages or []:
|
|
267
|
+
if msg.type not in role_map:
|
|
268
|
+
continue
|
|
269
|
+
role = role_map[msg.type]
|
|
270
|
+
if formatted and role != "system" and formatted[-1]["role"] == role:
|
|
271
|
+
formatted[-1]["content"] = formatted[-1]["content"] + "\n\n" + msg.text
|
|
272
|
+
else:
|
|
273
|
+
formatted.append({"role": role, "content": msg.text})
|
|
274
|
+
|
|
275
|
+
return formatted
|