Enhance Python host functionality

This commit is contained in:
qianlifeng 2024-12-05 23:07:22 +08:00
parent 1af4b7f1b0
commit a21dd8d509
No known key found for this signature in database
18 changed files with 1031 additions and 226 deletions

2
.gitignore vendored
View File

@ -20,3 +20,5 @@ __pycache__/
Release/ Release/
__debug_bin* __debug_bin*
Wox/log/ Wox/log/
Wox.Plugin.Python/dist/
Wox.Plugin.Python/wox_plugin.egg-info/

View File

@ -1,22 +1,51 @@
import asyncio import asyncio
import sys import sys
import uuid import uuid
import os
import logger import logger
from host import start_websocket from host import start_websocket
if len(sys.argv) != 4: if len(sys.argv) != 4:
print('Usage: python python-host.pyz <port> <logDirectory>') print('Usage: python python-host.pyz <port> <logDirectory> <woxPid>')
sys.exit(1) sys.exit(1)
port = int(sys.argv[1]) port = int(sys.argv[1])
log_directory = (sys.argv[2]) log_directory = sys.argv[2]
wox_pid = int(sys.argv[3])
trace_id = f"{uuid.uuid4()}" trace_id = str(uuid.uuid4())
host_id = f"python-{uuid.uuid4()}" host_id = f"python-{uuid.uuid4()}"
logger.update_log_directory(log_directory) logger.update_log_directory(log_directory)
logger.info(trace_id, "----------------------------------------")
logger.info(trace_id, f"start python host: {host_id}")
logger.info(trace_id, f"port: {port}")
asyncio.run(start_websocket(port)) def check_wox_process():
"""Check if Wox process is still alive"""
try:
os.kill(wox_pid, 0)
return True
except OSError:
return False
async def monitor_wox_process():
"""Monitor Wox process and exit if it's not alive"""
await logger.info(trace_id, "start monitor wox process")
while True:
if not check_wox_process():
await logger.error(trace_id, "wox process is not alive, exit")
sys.exit(1)
await asyncio.sleep(1)
async def main():
"""Main function"""
# Log startup information
await logger.info(trace_id, "----------------------------------------")
await logger.info(trace_id, f"start python host: {host_id}")
await logger.info(trace_id, f"port: {port}")
await logger.info(trace_id, f"wox pid: {wox_pid}")
# Start tasks
monitor_task = asyncio.create_task(monitor_wox_process())
websocket_task = asyncio.create_task(start_websocket(port))
await asyncio.gather(monitor_task, websocket_task)
asyncio.run(main())

View File

@ -0,0 +1,5 @@
"""Constants used across the application"""
PLUGIN_JSONRPC_TYPE_REQUEST = "WOX_JSONRPC_REQUEST"
PLUGIN_JSONRPC_TYPE_RESPONSE = "WOX_JSONRPC_RESPONSE"
PLUGIN_JSONRPC_TYPE_SYSTEM_LOG = "WOX_JSONRPC_SYSTEM_LOG"

View File

@ -1,23 +1,76 @@
#!/usr/bin/env python #!/usr/bin/env python
import asyncio import asyncio
import pkgutil import json
import uuid
from typing import Dict, Any
import websockets import websockets
from loguru import logger import logger
from wox_plugin import Context, new_context_with_value
from constants import PLUGIN_JSONRPC_TYPE_REQUEST, PLUGIN_JSONRPC_TYPE_RESPONSE
from plugin_manager import waiting_for_response
from jsonrpc import handle_request_from_wox
async def handle_message(ws: websockets.WebSocketServerProtocol, message: str):
"""Handle incoming WebSocket message"""
try:
msg_data = json.loads(message)
trace_id = msg_data.get("TraceId", str(uuid.uuid4()))
ctx = new_context_with_value("traceId", trace_id)
async def handler(websocket): if PLUGIN_JSONRPC_TYPE_RESPONSE in message:
while True: # Handle response from Wox
message = await websocket.recv() if msg_data.get("Id") in waiting_for_response:
logger.info(message) deferred = waiting_for_response[msg_data["Id"]]
# my_module = importlib.import_module('os.path') if msg_data.get("Error"):
deferred.reject(msg_data["Error"])
else:
deferred.resolve(msg_data.get("Result"))
del waiting_for_response[msg_data["Id"]]
elif PLUGIN_JSONRPC_TYPE_REQUEST in message:
# Handle request from Wox
try:
result = await handle_request_from_wox(ctx, msg_data, ws)
response = {
"TraceId": trace_id,
"Id": msg_data["Id"],
"Method": msg_data["Method"],
"Type": PLUGIN_JSONRPC_TYPE_RESPONSE,
"Result": result
}
await ws.send(json.dumps(response))
except Exception as e:
error_response = {
"TraceId": trace_id,
"Id": msg_data["Id"],
"Method": msg_data["Method"],
"Type": PLUGIN_JSONRPC_TYPE_RESPONSE,
"Error": str(e)
}
await logger.error(trace_id, f"handle request failed: {str(e)}")
await ws.send(json.dumps(error_response))
else:
await logger.error(trace_id, f"unknown message type: {message}")
except Exception as e:
await logger.error(str(uuid.uuid4()), f"receive and handle msg error: {message}, err: {str(e)}")
async def handler(websocket: websockets.WebSocketServerProtocol):
"""WebSocket connection handler"""
logger.update_websocket(websocket)
try:
async for message in websocket:
await handle_message(websocket, message)
except websockets.exceptions.ConnectionClosed:
await logger.info(str(uuid.uuid4()), "connection closed")
except Exception as e:
await logger.error(str(uuid.uuid4()), f"connection error: {str(e)}")
finally:
logger.update_websocket(None)
async def start_websocket(websocket_port: int): async def start_websocket(websocket_port: int):
"""Start WebSocket server"""
await logger.info(str(uuid.uuid4()), "start websocket server")
async with websockets.serve(handler, "", websocket_port): async with websockets.serve(handler, "", websocket_port):
await asyncio.Future() # run forever await asyncio.Future() # run forever
def load_plugin():
pkgutil.iter_modules(['plugins'])

View File

@ -0,0 +1,204 @@
import json
import importlib.util
import sys
from typing import Any, Dict, Optional
import uuid
import websockets
import logger
from wox_plugin import (
Context,
Plugin,
Query,
QueryType,
Selection,
QueryEnv,
Result,
new_context_with_value,
PluginInitParams
)
from constants import PLUGIN_JSONRPC_TYPE_REQUEST, PLUGIN_JSONRPC_TYPE_RESPONSE
from plugin_manager import plugin_instances, waiting_for_response
from plugin_api import PluginAPI
async def handle_request_from_wox(ctx: Context, request: Dict[str, Any], ws: websockets.WebSocketServerProtocol) -> Any:
"""Handle incoming request from Wox"""
method = request.get("Method")
plugin_name = request.get("PluginName")
await logger.info(ctx["Values"]["traceId"], f"invoke <{plugin_name}> method: {method}")
if method == "loadPlugin":
return await load_plugin(ctx, request)
elif method == "init":
return await init_plugin(ctx, request, ws)
elif method == "query":
return await query(ctx, request)
elif method == "action":
return await action(ctx, request)
elif method == "refresh":
return await refresh(ctx, request)
elif method == "unloadPlugin":
return await unload_plugin(ctx, request)
else:
await logger.info(ctx["Values"]["traceId"], f"unknown method handler: {method}")
raise Exception(f"unknown method handler: {method}")
async def load_plugin(ctx: Context, request: Dict[str, Any]) -> None:
"""Load a plugin"""
plugin_directory = request["Params"]["PluginDirectory"]
entry = request["Params"]["Entry"]
plugin_id = request["PluginId"]
plugin_name = request["PluginName"]
try:
# Add plugin directory to Python path
if plugin_directory not in sys.path:
sys.path.append(plugin_directory)
# Import the plugin module
spec = importlib.util.spec_from_file_location("plugin", entry)
if spec is None or spec.loader is None:
raise ImportError(f"Could not load plugin from {entry}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
if not hasattr(module, "plugin"):
raise AttributeError("Plugin module does not have a 'plugin' attribute")
plugin_instances[plugin_id] = {
"module": module,
"plugin": module.plugin,
"directory": plugin_directory,
"entry": entry,
"name": plugin_name,
"api": None
}
await logger.info(ctx["Values"]["traceId"], f"<{plugin_name}> load plugin successfully")
except Exception as e:
await logger.error(ctx["Values"]["traceId"], f"<{plugin_name}> load plugin failed: {str(e)}")
raise e
async def init_plugin(ctx: Context, request: Dict[str, Any], ws: websockets.WebSocketServerProtocol) -> None:
"""Initialize a plugin"""
plugin_id = request["PluginId"]
plugin = plugin_instances.get(plugin_id)
if not plugin:
raise Exception(f"plugin not found: {request['PluginName']}, forget to load plugin?")
try:
# Create plugin API instance
api = PluginAPI(ws, plugin_id, plugin["name"])
plugin["api"] = api
# Call plugin's init method if it exists
if hasattr(plugin["plugin"], "init"):
init_params = PluginInitParams(API=api, PluginDirectory=plugin["directory"])
await plugin["plugin"].init(ctx, init_params)
await logger.info(ctx["Values"]["traceId"], f"<{plugin['name']}> init plugin successfully")
except Exception as e:
await logger.error(ctx["Values"]["traceId"], f"<{plugin['name']}> init plugin failed: {str(e)}")
raise e
async def query(ctx: Context, request: Dict[str, Any]) -> list:
"""Handle query request"""
plugin_id = request["PluginId"]
plugin = plugin_instances.get(plugin_id)
if not plugin:
raise Exception(f"plugin not found: {request['PluginName']}, forget to load plugin?")
try:
if not hasattr(plugin["plugin"], "query"):
return []
query_params = Query(
Type=QueryType(request["Params"]["Type"]),
RawQuery=request["Params"]["RawQuery"],
TriggerKeyword=request["Params"]["TriggerKeyword"],
Command=request["Params"]["Command"],
Search=request["Params"]["Search"],
Selection=Selection(**json.loads(request["Params"]["Selection"])),
Env=QueryEnv(**json.loads(request["Params"]["Env"]))
)
results = await plugin["plugin"].query(ctx, query_params)
# Ensure each result has an ID
if results:
for result in results:
if not result.Id:
result.Id = str(uuid.uuid4())
if result.Actions:
for action in result.Actions:
if not action.Id:
action.Id = str(uuid.uuid4())
return [result.__dict__ for result in results] if results else []
except Exception as e:
await logger.error(ctx["Values"]["traceId"], f"<{plugin['name']}> query failed: {str(e)}")
raise e
async def action(ctx: Context, request: Dict[str, Any]) -> Any:
"""Handle action request"""
plugin_id = request["PluginId"]
plugin = plugin_instances.get(plugin_id)
if not plugin:
raise Exception(f"plugin not found: {request['PluginName']}, forget to load plugin?")
try:
action_id = request["Params"]["ActionId"]
context_data = request["Params"].get("ContextData")
# Find the action in the plugin's results
if hasattr(plugin["plugin"], "handle_action"):
return await plugin["plugin"].handle_action(action_id, context_data)
return None
except Exception as e:
await logger.error(ctx["Values"]["traceId"], f"<{plugin['name']}> action failed: {str(e)}")
raise e
async def refresh(ctx: Context, request: Dict[str, Any]) -> Any:
"""Handle refresh request"""
plugin_id = request["PluginId"]
plugin = plugin_instances.get(plugin_id)
if not plugin:
raise Exception(f"plugin not found: {request['PluginName']}, forget to load plugin?")
try:
result_id = request["Params"]["ResultId"]
# Find the refresh callback in the plugin's results
if hasattr(plugin["plugin"], "handle_refresh"):
return await plugin["plugin"].handle_refresh(result_id)
return None
except Exception as e:
await logger.error(ctx["Values"]["traceId"], f"<{plugin['name']}> refresh failed: {str(e)}")
raise e
async def unload_plugin(ctx: Context, request: Dict[str, Any]) -> None:
"""Unload a plugin"""
plugin_id = request["PluginId"]
plugin = plugin_instances.get(plugin_id)
if not plugin:
raise Exception(f"plugin not found: {request['PluginName']}, forget to load plugin?")
try:
# Call plugin's unload method if it exists
if hasattr(plugin["plugin"], "unload"):
await plugin["plugin"].unload()
# Remove plugin from instances
del plugin_instances[plugin_id]
# Remove plugin directory from Python path
if plugin["directory"] in sys.path:
sys.path.remove(plugin["directory"])
await logger.info(ctx["Values"]["traceId"], f"<{plugin['name']}> unload plugin successfully")
except Exception as e:
await logger.error(ctx["Values"]["traceId"], f"<{plugin['name']}> unload plugin failed: {str(e)}")
raise e

View File

@ -1,22 +1,41 @@
from loguru import logger as loguru_logger import json
from typing import Optional
import websockets
from loguru import logger
PLUGIN_JSONRPC_TYPE_SYSTEM_LOG = "WOX_JSONRPC_SYSTEM_LOG"
websocket: Optional[websockets.WebSocketServerProtocol] = None
def update_log_directory(log_directory: str) -> None: def update_log_directory(log_directory: str):
loguru_logger.remove() """Update the log directory for the logger"""
loguru_logger.add(f"{log_directory}/python.log", format="{time:YYYY-MM-DD HH:mm:ss.SSS} [{level}] {message}", rotation="100 MB", retention="3 days") logger.remove()
logger.add(f"{log_directory}/python.log", format="{time} {message}")
def update_websocket(ws: Optional[websockets.WebSocketServerProtocol]):
"""Update the websocket connection for logging"""
global websocket
websocket = ws
def debug(trace_id: str, message: str) -> None: async def log(trace_id: str, level: str, msg: str):
__inner_log(trace_id, message, "debug") """Log a message to both file and websocket if available"""
logger.log(level.upper(), f"{trace_id} [{level}] {msg}")
if websocket:
try:
await websocket.send(json.dumps({
"Type": PLUGIN_JSONRPC_TYPE_SYSTEM_LOG,
"TraceId": trace_id,
"Level": level,
"Message": msg
}))
except Exception as e:
logger.error(f"Failed to send log message through websocket: {e}")
def info(trace_id: str, message: str) -> None: async def debug(trace_id: str, msg: str):
__inner_log(trace_id, message, "info") await log(trace_id, "debug", msg)
async def info(trace_id: str, msg: str):
await log(trace_id, "info", msg)
def error(trace_id: str, message: str) -> None: async def error(trace_id: str, msg: str):
__inner_log(trace_id, message, "error") await log(trace_id, "error", msg)
def __inner_log(trace_id: str, message: str, level: str) -> None:
loguru_logger.log(__level=level, __message=f"{trace_id} {message}")

View File

@ -0,0 +1,144 @@
import asyncio
import json
import uuid
from typing import Any, Dict, Callable, Optional
import websockets
import logger
from wox_plugin import (
Context,
PublicAPI,
ChangeQueryParam,
MetadataCommand,
PluginSettingDefinitionItem,
MapString,
Conversation,
ChatStreamFunc,
)
from jsonrpc import PLUGIN_JSONRPC_TYPE_REQUEST, waiting_for_response
class PluginAPI(PublicAPI):
def __init__(self, ws: websockets.WebSocketServerProtocol, plugin_id: str, plugin_name: str):
self.ws = ws
self.plugin_id = plugin_id
self.plugin_name = plugin_name
self.setting_change_callbacks: Dict[str, Callable[[str, str], None]] = {}
self.get_dynamic_setting_callbacks: Dict[str, Callable[[str], PluginSettingDefinitionItem]] = {}
self.deep_link_callbacks: Dict[str, Callable[[MapString], None]] = {}
self.unload_callbacks: Dict[str, Callable[[], None]] = {}
self.llm_stream_callbacks: Dict[str, ChatStreamFunc] = {}
async def invoke_method(self, ctx: Context, method: str, params: Dict[str, Any]) -> Any:
"""Invoke a method on Wox"""
request_id = str(uuid.uuid4())
trace_id = ctx["Values"]["traceId"]
if method != "Log":
await logger.info(trace_id, f"<{self.plugin_name}> start invoke method to Wox: {method}, id: {request_id}")
request = {
"TraceId": trace_id,
"Id": request_id,
"Method": method,
"Type": PLUGIN_JSONRPC_TYPE_REQUEST,
"Params": params,
"PluginId": self.plugin_id,
"PluginName": self.plugin_name
}
await self.ws.send(json.dumps(request))
# Create a Future to wait for the response
future = asyncio.Future()
waiting_for_response[request_id] = future
try:
return await future
except Exception as e:
await logger.error(trace_id, f"invoke method failed: {str(e)}")
raise e
async def change_query(self, ctx: Context, query: ChangeQueryParam) -> None:
"""Change the query in Wox"""
params = {
"QueryType": query.QueryType,
"QueryText": query.QueryText,
"QuerySelection": query.QuerySelection.__dict__ if query.QuerySelection else None
}
await self.invoke_method(ctx, "ChangeQuery", params)
async def hide_app(self, ctx: Context) -> None:
"""Hide the Wox window"""
await self.invoke_method(ctx, "HideApp", {})
async def show_app(self, ctx: Context) -> None:
"""Show the Wox window"""
await self.invoke_method(ctx, "ShowApp", {})
async def notify(self, ctx: Context, message: str) -> None:
"""Show a notification message"""
await self.invoke_method(ctx, "Notify", {"message": message})
async def log(self, ctx: Context, level: str, msg: str) -> None:
"""Write log"""
await self.invoke_method(ctx, "Log", {
"level": level,
"message": msg
})
async def get_translation(self, ctx: Context, key: str) -> str:
"""Get a translation for a key"""
result = await self.invoke_method(ctx, "GetTranslation", {"key": key})
return str(result) if result is not None else key
async def get_setting(self, ctx: Context, key: str) -> str:
"""Get a setting value"""
result = await self.invoke_method(ctx, "GetSetting", {"key": key})
return str(result) if result is not None else ""
async def save_setting(self, ctx: Context, key: str, value: str, is_platform_specific: bool) -> None:
"""Save a setting value"""
await self.invoke_method(ctx, "SaveSetting", {
"key": key,
"value": value,
"isPlatformSpecific": is_platform_specific
})
async def on_setting_changed(self, ctx: Context, callback: Callable[[str, str], None]) -> None:
"""Register setting changed callback"""
callback_id = str(uuid.uuid4())
self.setting_change_callbacks[callback_id] = callback
await self.invoke_method(ctx, "OnSettingChanged", {"callbackId": callback_id})
async def on_get_dynamic_setting(self, ctx: Context, callback: Callable[[str], PluginSettingDefinitionItem]) -> None:
"""Register dynamic setting callback"""
callback_id = str(uuid.uuid4())
self.get_dynamic_setting_callbacks[callback_id] = callback
await self.invoke_method(ctx, "OnGetDynamicSetting", {"callbackId": callback_id})
async def on_deep_link(self, ctx: Context, callback: Callable[[MapString], None]) -> None:
"""Register deep link callback"""
callback_id = str(uuid.uuid4())
self.deep_link_callbacks[callback_id] = callback
await self.invoke_method(ctx, "OnDeepLink", {"callbackId": callback_id})
async def on_unload(self, ctx: Context, callback: Callable[[], None]) -> None:
"""Register unload callback"""
callback_id = str(uuid.uuid4())
self.unload_callbacks[callback_id] = callback
await self.invoke_method(ctx, "OnUnload", {"callbackId": callback_id})
async def register_query_commands(self, ctx: Context, commands: list[MetadataCommand]) -> None:
"""Register query commands"""
await self.invoke_method(ctx, "RegisterQueryCommands", {
"commands": json.dumps([command.__dict__ for command in commands])
})
async def llm_stream(self, ctx: Context, conversations: list[Conversation], callback: ChatStreamFunc) -> None:
"""Chat using LLM"""
callback_id = str(uuid.uuid4())
self.llm_stream_callbacks[callback_id] = callback
await self.invoke_method(ctx, "LLMStream", {
"callbackId": callback_id,
"conversations": json.dumps([conv.__dict__ for conv in conversations])
})

View File

@ -0,0 +1,6 @@
"""Plugin manager for handling plugin instances and responses"""
from typing import Dict, Any
# Global state
plugin_instances: Dict[str, Dict[str, Any]] = {}
waiting_for_response: Dict[str, Any] = {}

View File

@ -1,2 +1,3 @@
websockets==12.0
loguru==0.7.2 loguru==0.7.2
websockets==11.0.3 wox-plugin==0.0.1

View File

@ -0,0 +1,27 @@
# Wox Plugin Python
This package provides type definitions for developing Wox plugins in Python.
## Installation
```bash
pip install wox-plugin
```
## Usage
```python
from wox_plugin import Plugin, Query, Result, Context, PluginInitParams
class MyPlugin(Plugin):
async def init(self, ctx: Context, params: PluginInitParams) -> None:
self.api = params.API
async def query(self, ctx: Context, query: Query) -> list[Result]:
# Your plugin logic here
return []
```
## License
MIT

View File

@ -1,188 +0,0 @@
from typing import List, Dict, Any, Optional, Callable, Union
from abc import ABC, abstractmethod
from enum import Enum
class Platform(Enum):
WINDOWS = "windows"
DARWIN = "darwin"
LINUX = "linux"
class SelectionType(Enum):
TEXT = "text"
FILE = "file"
class Selection:
def __init__(self, type: SelectionType, text: Optional[str] = None, file_paths: Optional[List[str]] = None):
self.Type = type
self.Text = text
self.FilePaths = file_paths
class QueryEnv:
def __init__(self, active_window_title: str):
self.ActiveWindowTitle = active_window_title
class Query:
def __init__(self, type: str, raw_query: str, trigger_keyword: Optional[str], command: Optional[str], search: str, selection: Optional[Selection], env: QueryEnv):
self.Type = type
self.RawQuery = raw_query
self.TriggerKeyword = trigger_keyword
self.Command = command
self.Search = search
self.Selection = selection
self.Env = env
def is_global_query(self) -> bool:
return self.TriggerKeyword is None or self.TriggerKeyword == ""
class WoxImageType(Enum):
ABSOLUTE = "absolute"
RELATIVE = "relative"
BASE64 = "base64"
SVG = "svg"
URL = "url"
EMOJI = "emoji"
LOTTIE = "lottie"
class WoxImage:
def __init__(self, image_type: WoxImageType, image_data: str):
self.ImageType = image_type
self.ImageData = image_data
class WoxPreviewType(Enum):
MARKDOWN = "markdown"
TEXT = "text"
IMAGE = "image"
URL = "url"
FILE = "file"
class WoxPreview:
def __init__(self, preview_type: WoxPreviewType, preview_data: str, preview_properties: Dict[str, str]):
self.PreviewType = preview_type
self.PreviewData = preview_data
self.PreviewProperties = preview_properties
class ResultTail:
def __init__(self, type: str, text: Optional[str] = None, image: Optional[WoxImage] = None):
self.Type = type
self.Text = text
self.Image = image
class ActionContext:
def __init__(self, context_data: str):
self.ContextData = context_data
class ResultAction:
def __init__(self, id: Optional[str], name: str, icon: Optional[WoxImage], is_default: bool, prevent_hide_after_action: bool, action: Callable[[ActionContext], None], hotkey: Optional[str]):
self.Id = id
self.Name = name
self.Icon = icon
self.IsDefault = is_default
self.PreventHideAfterAction = prevent_hide_after_action
self.Action = action
self.Hotkey = hotkey
class Result:
def __init__(self, id: Optional[str], title: str, sub_title: Optional[str], icon: WoxImage, preview: Optional[WoxPreview], score: Optional[float], group: Optional[str], group_score: Optional[float], tails: Optional[List[ResultTail]], context_data: Optional[str], actions: Optional[List[ResultAction]], refresh_interval: Optional[int], on_refresh: Optional[Callable[['RefreshableResult'], 'RefreshableResult']]):
self.Id = id
self.Title = title
self.SubTitle = sub_title
self.Icon = icon
self.Preview = preview
self.Score = score
self.Group = group
self.GroupScore = group_score
self.Tails = tails
self.ContextData = context_data
self.Actions = actions
self.RefreshInterval = refresh_interval
self.OnRefresh = on_refresh
class RefreshableResult:
def __init__(self, title: str, sub_title: str, icon: WoxImage, preview: WoxPreview, context_data: str, refresh_interval: int):
self.Title = title
self.SubTitle = sub_title
self.Icon = icon
self.Preview = preview
self.ContextData = context_data
self.RefreshInterval = refresh_interval
class ChangeQueryParam:
def __init__(self, query_type: str, query_text: Optional[str] = None, query_selection: Optional[Selection] = None):
self.QueryType = query_type
self.QueryText = query_text
self.QuerySelection = query_selection
class Context:
def __init__(self):
self.Values = {}
def get(self, key: str) -> Optional[str]:
return self.Values.get(key)
def set(self, key: str, value: str):
self.Values[key] = value
def exists(self, key: str) -> bool:
return key in self.Values
class PublicAPI:
@staticmethod
async def change_query(ctx: Context, query: ChangeQueryParam):
pass
@staticmethod
async def hide_app(ctx: Context):
pass
@staticmethod
async def show_app(ctx: Context):
pass
@staticmethod
async def notify(ctx: Context, title: str, description: Optional[str] = None):
pass
@staticmethod
async def log(ctx: Context, level: str, msg: str):
pass
@staticmethod
async def get_translation(ctx: Context, key: str) -> str:
pass
@staticmethod
async def get_setting(ctx: Context, key: str) -> str:
pass
@staticmethod
async def save_setting(ctx: Context, key: str, value: str, is_platform_specific: bool):
pass
@staticmethod
async def on_setting_changed(ctx: Context, callback: Callable[[str, str], None]):
pass
class PluginInitParams:
def __init__(self, api: PublicAPI, plugin_directory: str):
self.API = api
self.PluginDirectory = plugin_directory
class WoxPlugin(ABC):
@abstractmethod
async def init(self, ctx: Context, init_params: PluginInitParams):
pass
@abstractmethod
async def query(self, ctx: Context, query: Query) -> List[Result]:
pass
def new_context() -> Context:
return Context()
def new_context_with_value(key: str, value: str) -> Context:
ctx = Context()
ctx.set(key, value)
return ctx
def new_base64_wox_image(image_data: str) -> WoxImage:
return WoxImage(WoxImageType.BASE64, image_data)

View File

@ -0,0 +1,94 @@
#!/usr/bin/env python
import os
import sys
import subprocess
import re
from pathlib import Path
def run_command(command: str) -> int:
"""Run command and return exit code"""
print(f"\n>>> Running: {command}")
return subprocess.call(command, shell=True)
def update_version(version_type: str) -> str:
"""Update version number
version_type: major, minor, or patch
"""
# Read setup.py
setup_path = Path("setup.py")
content = setup_path.read_text()
# Find current version
version_match = re.search(r'version="(\d+)\.(\d+)\.(\d+)"', content)
if not version_match:
print("Error: Could not find version in setup.py")
sys.exit(1)
major, minor, patch = map(int, version_match.groups())
# Update version number
if version_type == "major":
major += 1
minor = 0
patch = 0
elif version_type == "minor":
minor += 1
patch = 0
else: # patch
patch += 1
new_version = f"{major}.{minor}.{patch}"
# Update setup.py
new_content = re.sub(
r'version="(\d+)\.(\d+)\.(\d+)"',
f'version="{new_version}"',
content
)
setup_path.write_text(new_content)
# Update __init__.py
init_path = Path("wox_plugin/__init__.py")
init_content = init_path.read_text()
new_init_content = re.sub(
r'__version__ = "(\d+)\.(\d+)\.(\d+)"',
f'__version__ = "{new_version}"',
init_content
)
init_path.write_text(new_init_content)
return new_version
def main():
# Check command line arguments
if len(sys.argv) != 2 or sys.argv[1] not in ["major", "minor", "patch"]:
print("Usage: python publish.py [major|minor|patch]")
sys.exit(1)
version_type = sys.argv[1]
# Clean previous build files
if run_command("rm -rf dist/ build/ *.egg-info"):
print("Error: Failed to clean old build files")
sys.exit(1)
# Update version number
new_version = update_version(version_type)
print(f"Updated version to {new_version}")
# Build package
if run_command("python -m build"):
print("Error: Build failed")
sys.exit(1)
# Upload to PyPI
if run_command("python -m twine upload dist/*"):
print("Error: Upload to PyPI failed")
sys.exit(1)
print(f"\nSuccessfully published version {new_version} to PyPI!")
print("Package can be installed with:")
print(f"pip install wox-plugin=={new_version}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1 @@
python publish.py patch

View File

@ -0,0 +1,34 @@
from setuptools import setup, find_packages
setup(
name="wox-plugin",
version="0.0.1",
description="All Python plugins for Wox should use types in this package",
long_description=open("README.md").read(),
long_description_content_type="text/markdown",
author="Wox-launcher",
author_email="",
url="https://github.com/Wox-launcher/Wox",
packages=find_packages(),
install_requires=[
"typing_extensions>=4.0.0; python_version < '3.8'"
],
python_requires=">=3.8",
classifiers=[
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Operating System :: OS Independent",
"Typing :: Typed",
],
keywords="wox launcher plugin types",
project_urls={
"Bug Reports": "https://github.com/Wox-launcher/Wox/issues",
"Source": "https://github.com/Wox-launcher/Wox",
},
)

View File

@ -0,0 +1,55 @@
from .types import (
# Basic types
MapString,
Platform,
# Context
Context,
new_context,
new_context_with_value,
# Selection
SelectionType,
Selection,
# Query
QueryType,
Query,
QueryEnv,
# Result
WoxImageType,
WoxImage,
new_base64_wox_image,
WoxPreviewType,
WoxPreview,
ResultTailType,
ResultTail,
ActionContext,
ResultAction,
Result,
RefreshableResult,
# Plugin API
ChangeQueryParam,
# AI
ConversationRole,
ChatStreamDataType,
Conversation,
ChatStreamFunc,
# Settings
PluginSettingDefinitionType,
PluginSettingValueStyle,
PluginSettingDefinitionValue,
PluginSettingDefinitionItem,
MetadataCommand,
# Plugin Interface
Plugin,
PublicAPI,
PluginInitParams,
)
__version__ = "0.0.82"

View File

@ -0,0 +1,261 @@
from dataclasses import dataclass
from enum import Enum
from typing import Dict, List, Optional, Protocol, Union, Callable, Any, TypedDict, Literal
import uuid
# Basic types
MapString = Dict[str, str]
Platform = Literal["windows", "darwin", "linux"]
# Context
class Context(TypedDict):
Values: Dict[str, str]
def new_context() -> Context:
return {"Values": {"traceId": str(uuid.uuid4())}}
def new_context_with_value(key: str, value: str) -> Context:
ctx = new_context()
ctx["Values"][key] = value
return ctx
# Selection
class SelectionType(str, Enum):
TEXT = "text"
FILE = "file"
@dataclass
class Selection:
Type: SelectionType
Text: Optional[str] = None
FilePaths: Optional[List[str]] = None
# Query Environment
@dataclass
class QueryEnv:
ActiveWindowTitle: str
# Query
class QueryType(str, Enum):
INPUT = "input"
SELECTION = "selection"
@dataclass
class Query:
Type: QueryType
RawQuery: str
TriggerKeyword: Optional[str]
Command: Optional[str]
Search: str
Selection: Selection
Env: QueryEnv
def is_global_query(self) -> bool:
return self.Type == QueryType.INPUT and not self.TriggerKeyword
# Result
class WoxImageType(str, Enum):
ABSOLUTE = "absolute"
RELATIVE = "relative"
BASE64 = "base64"
SVG = "svg"
URL = "url"
EMOJI = "emoji"
LOTTIE = "lottie"
@dataclass
class WoxImage:
ImageType: WoxImageType
ImageData: str
def new_base64_wox_image(image_data: str) -> WoxImage:
return WoxImage(ImageType=WoxImageType.BASE64, ImageData=image_data)
class WoxPreviewType(str, Enum):
MARKDOWN = "markdown"
TEXT = "text"
IMAGE = "image"
URL = "url"
FILE = "file"
@dataclass
class WoxPreview:
PreviewType: WoxPreviewType
PreviewData: str
PreviewProperties: Dict[str, str]
class ResultTailType(str, Enum):
TEXT = "text"
IMAGE = "image"
@dataclass
class ResultTail:
Type: ResultTailType
Text: Optional[str] = None
Image: Optional[WoxImage] = None
@dataclass
class ActionContext:
ContextData: str
@dataclass
class ResultAction:
Id: Optional[str]
Name: str
Icon: Optional[WoxImage]
IsDefault: Optional[bool]
PreventHideAfterAction: Optional[bool]
Action: Callable[[ActionContext], None]
Hotkey: Optional[str]
@dataclass
class Result:
Id: Optional[str]
Title: str
SubTitle: Optional[str]
Icon: WoxImage
Preview: Optional[WoxPreview]
Score: Optional[float]
Group: Optional[str]
GroupScore: Optional[float]
Tails: Optional[List[ResultTail]]
ContextData: Optional[str]
Actions: Optional[List[ResultAction]]
RefreshInterval: Optional[int]
OnRefresh: Optional[Callable[["RefreshableResult"], "RefreshableResult"]]
@dataclass
class RefreshableResult:
Title: str
SubTitle: str
Icon: WoxImage
Preview: WoxPreview
Tails: List[ResultTail]
ContextData: str
RefreshInterval: int
Actions: List[ResultAction]
# Plugin API
@dataclass
class ChangeQueryParam:
QueryType: QueryType
QueryText: Optional[str]
QuerySelection: Optional[Selection]
# AI
class ConversationRole(str, Enum):
USER = "user"
SYSTEM = "system"
class ChatStreamDataType(str, Enum):
STREAMING = "streaming"
FINISHED = "finished"
ERROR = "error"
@dataclass
class Conversation:
Role: ConversationRole
Text: str
Timestamp: int
ChatStreamFunc = Callable[[ChatStreamDataType, str], None]
# Settings
class PluginSettingDefinitionType(str, Enum):
HEAD = "head"
TEXTBOX = "textbox"
CHECKBOX = "checkbox"
SELECT = "select"
LABEL = "label"
NEWLINE = "newline"
TABLE = "table"
DYNAMIC = "dynamic"
@dataclass
class PluginSettingValueStyle:
PaddingLeft: int
PaddingTop: int
PaddingRight: int
PaddingBottom: int
Width: int
LabelWidth: int
@dataclass
class PluginSettingDefinitionValue:
def get_key(self) -> str:
raise NotImplementedError
def get_default_value(self) -> str:
raise NotImplementedError
def translate(self, translator: Callable[[Context, str], str]) -> None:
raise NotImplementedError
@dataclass
class PluginSettingDefinitionItem:
Type: PluginSettingDefinitionType
Value: PluginSettingDefinitionValue
DisabledInPlatforms: List[Platform]
IsPlatformSpecific: bool
@dataclass
class MetadataCommand:
Command: str
Description: str
# Plugin Interface
class Plugin(Protocol):
async def init(self, ctx: Context, init_params: "PluginInitParams") -> None:
...
async def query(self, ctx: Context, query: Query) -> List[Result]:
...
# Public API Interface
class PublicAPI(Protocol):
async def change_query(self, ctx: Context, query: ChangeQueryParam) -> None:
...
async def hide_app(self, ctx: Context) -> None:
...
async def show_app(self, ctx: Context) -> None:
...
async def notify(self, ctx: Context, message: str) -> None:
...
async def log(self, ctx: Context, level: str, msg: str) -> None:
...
async def get_translation(self, ctx: Context, key: str) -> str:
...
async def get_setting(self, ctx: Context, key: str) -> str:
...
async def save_setting(self, ctx: Context, key: str, value: str, is_platform_specific: bool) -> None:
...
async def on_setting_changed(self, ctx: Context, callback: Callable[[str, str], None]) -> None:
...
async def on_get_dynamic_setting(self, ctx: Context, callback: Callable[[str], PluginSettingDefinitionItem]) -> None:
...
async def on_deep_link(self, ctx: Context, callback: Callable[[MapString], None]) -> None:
...
async def on_unload(self, ctx: Context, callback: Callable[[], None]) -> None:
...
async def register_query_commands(self, ctx: Context, commands: List[MetadataCommand]) -> None:
...
async def llm_stream(self, ctx: Context, conversations: List[Conversation], callback: ChatStreamFunc) -> None:
...
@dataclass
class PluginInitParams:
API: PublicAPI
PluginDirectory: str

View File

@ -2,9 +2,14 @@ package host
import ( import (
"context" "context"
"fmt"
"path" "path"
"strings"
"wox/plugin" "wox/plugin"
"wox/util" "wox/util"
"github.com/Masterminds/semver/v3"
"github.com/mitchellh/go-homedir"
) )
func init() { func init() {
@ -25,7 +30,61 @@ func (n *PythonHost) GetRuntime(ctx context.Context) plugin.Runtime {
} }
func (n *PythonHost) Start(ctx context.Context) error { func (n *PythonHost) Start(ctx context.Context) error {
return n.websocketHost.StartHost(ctx, "python", path.Join(util.GetLocation().GetHostDirectory(), "python-host.pyz")) return n.websocketHost.StartHost(ctx, n.findPythonPath(ctx), path.Join(util.GetLocation().GetHostDirectory(), "python-host.pyz"))
}
func (n *PythonHost) findPythonPath(ctx context.Context) string {
util.GetLogger().Debug(ctx, "start finding python path")
var possiblePythonPaths = []string{
"/opt/homebrew/bin/python3",
"/usr/local/bin/python3",
"/usr/bin/python3",
"/usr/local/python3",
}
pyenvPaths, _ := homedir.Expand("~/.pyenv/versions")
if util.IsDirExists(pyenvPaths) {
versions, _ := util.ListDir(pyenvPaths)
for _, v := range versions {
possiblePythonPaths = append(possiblePythonPaths, path.Join(pyenvPaths, v, "bin", "python3"))
}
}
foundVersion, _ := semver.NewVersion("v0.0.1")
foundPath := ""
for _, p := range possiblePythonPaths {
if util.IsFileExists(p) {
versionOriginal, versionErr := util.ShellRunOutput(p, "--version")
if versionErr != nil {
util.GetLogger().Error(ctx, fmt.Sprintf("failed to get python version: %s, path=%s", versionErr, p))
continue
}
// Python version output format is like "Python 3.9.0"
version := strings.TrimSpace(string(versionOriginal))
version = strings.TrimPrefix(version, "Python ")
version = "v" + version
installedVersion, err := semver.NewVersion(version)
if err != nil {
util.GetLogger().Error(ctx, fmt.Sprintf("failed to parse python version: %s, path=%s", err, p))
continue
}
util.GetLogger().Debug(ctx, fmt.Sprintf("found python path: %s, version: %s", p, installedVersion.String()))
if installedVersion.GreaterThan(foundVersion) {
foundPath = p
foundVersion = installedVersion
}
}
}
if foundPath != "" {
util.GetLogger().Info(ctx, fmt.Sprintf("finally use python path: %s, version: %s", foundPath, foundVersion.String()))
return foundPath
}
util.GetLogger().Info(ctx, "finally use default python3 from env path")
return "python3"
} }
func (n *PythonHost) IsStarted(ctx context.Context) bool { func (n *PythonHost) IsStarted(ctx context.Context) bool {

View File

@ -141,8 +141,7 @@ func (m *Manager) loadPlugins(ctx context.Context) error {
} }
logger.Info(ctx, fmt.Sprintf("start loading user plugins, found %d user plugins", len(metaDataList))) logger.Info(ctx, fmt.Sprintf("start loading user plugins, found %d user plugins", len(metaDataList)))
for _, h := range AllHosts { for _, host := range AllHosts {
host := h
util.Go(ctx, fmt.Sprintf("[%s] start host", host.GetRuntime(ctx)), func() { util.Go(ctx, fmt.Sprintf("[%s] start host", host.GetRuntime(ctx)), func() {
newCtx := util.NewTraceContext() newCtx := util.NewTraceContext()
hostErr := host.Start(newCtx) hostErr := host.Start(newCtx)