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.
@@ -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