diff --git a/operit_py/12306.py b/operit_py/12306.py
new file mode 100644
index 0000000..529e640
--- /dev/null
+++ b/operit_py/12306.py
@@ -0,0 +1,744 @@
+# METADATA
+# {
+# "name": "12306_ticket",
+# "display_name": {"zh": "12306 拓展", "en": "12306 Extension"},
+# "description": {"zh": "提供12306火车票信息查询功能,包括余票、中转、经停站等。", "en": "Query China Railway 12306 train ticket information, including availability, transfer routes, and stop stations."},
+# "enabledByDefault": True,
+# "category": "Life",
+# "tools": [
+# {"name": "get_current_date", "description": {"zh": "获取当前日期,以上海时区(Asia/Shanghai, UTC+8)为准,返回格式为 'yyyy-MM-dd'。", "en": "Get the current date in the Shanghai timezone (Asia/Shanghai, UTC+8). Returns format 'yyyy-MM-dd'."}, "parameters": []},
+# {"name": "get_stations_code_in_city", "description": {"zh": "通过中文城市名查询该城市所有火车站的名称及其对应的 station_code。", "en": "Given a Chinese city name, list all train stations in that city and their corresponding station_code."}, "parameters": [{"name": "city", "description": {"zh": "中文城市名称,例如:'北京', '上海'", "en": "Chinese city name, e.g. '北京', '上海'."}, "type": "string", "required": True}]},
+# {"name": "get_station_code_of_citys", "description": {"zh": "通过中文城市名查询代表该城市的 station_code。", "en": "Get the representative station_code for a Chinese city name."}, "parameters": [{"name": "citys", "description": {"zh": "要查询的城市,比如'北京'。若要查询多个城市,请用|分割。", "en": "City to query, e.g. '北京'. For multiple cities, separate with |."}, "type": "string", "required": True}]},
+# {"name": "get_station_code_by_names", "description": {"zh": "通过具体的中文车站名查询其 station_code 和车站名。", "en": "Given a specific Chinese station name, return its station_code and station name."}, "parameters": [{"name": "station_names", "description": {"zh": "具体的中文车站名称,例如:'北京南', '上海虹桥'。多个站点用|分割。", "en": "Specific Chinese station names, e.g. '北京南', '上海虹桥'. For multiple stations, separate with |."}, "type": "string", "required": True}]},
+# {"name": "get_station_by_telecode", "description": {"zh": "通过车站的 station_telecode 查询车站的详细信息。", "en": "Query station details by station_telecode."}, "parameters": [{"name": "station_telecode", "description": {"zh": "车站的 station_telecode (3位字母编码)", "en": "Station station_telecode (3-letter code)."}, "type": "string", "required": True}]},
+# {"name": "get_tickets", "description": {"zh": "查询12306余票信息。", "en": "Query 12306 ticket availability."}, "parameters": [
+# {"name": "date", "description": {"zh": "查询日期,格式为 'yyyy-MM-dd'。", "en": "Query date in 'yyyy-MM-dd'."}, "type": "string", "required": True},
+# {"name": "from_station", "description": {"zh": "出发地的 station_code。", "en": "Origin station_code."}, "type": "string", "required": True},
+# {"name": "to_station", "description": {"zh": "到达地的 station_code。", "en": "Destination station_code."}, "type": "string", "required": True},
+# {"name": "train_filter_flags", "description": {"zh": "车次筛选条件,默认为空。可选标志:[G,D,Z,T,K,O,F,S]", "en": "Train filter flags. Options: [G,D,Z,T,K,O,F,S]."}, "type": "string", "required": False},
+# {"name": "sort_flag", "description": {"zh": "排序方式。可选标志:[startTime, arriveTime, duration]", "en": "Sort mode. Options: startTime / arriveTime / duration."}, "type": "string", "required": False},
+# {"name": "sort_reverse", "description": {"zh": "是否逆向排序结果,默认为false。", "en": "Reverse sort order (default: false)."}, "type": "boolean", "required": False},
+# {"name": "limited_num", "description": {"zh": "返回的余票数量限制,默认为0,即不限制。", "en": "Limit number of returned results (default: 0, no limit)."}, "type": "number", "required": False}
+# ]},
+# {"name": "get_interline_tickets", "description": {"zh": "查询12306中转余票信息。尚且只支持查询前十条。", "en": "Query 12306 transfer (interline) ticket availability. Currently only supports the first 10 results."}, "parameters": [
+# {"name": "date", "description": {"zh": "查询日期,格式为 'yyyy-MM-dd'。", "en": "Query date in 'yyyy-MM-dd'."}, "type": "string", "required": True},
+# {"name": "from_station", "description": {"zh": "出发地的 station_code。", "en": "Origin station_code."}, "type": "string", "required": True},
+# {"name": "to_station", "description": {"zh": "到达地的 station_code。", "en": "Destination station_code."}, "type": "string", "required": True},
+# {"name": "middle_station", "description": {"zh": "中转地的 station_code,可选。", "en": "Optional transfer station station_code."}, "type": "string", "required": False},
+# {"name": "show_wz", "description": {"zh": "是否显示无座车,默认不显示无座车。", "en": "Whether to include no-seat tickets (default: false)."}, "type": "boolean", "required": False},
+# {"name": "train_filter_flags", "description": {"zh": "车次筛选条件,默认为空。", "en": "Train filter flags. Default empty."}, "type": "string", "required": False},
+# {"name": "sort_flag", "description": {"zh": "排序方式。", "en": "Sort mode."}, "type": "string", "required": False},
+# {"name": "sort_reverse", "description": {"zh": "是否逆向排序结果,默认为false。", "en": "Reverse sort order (default: false)."}, "type": "boolean", "required": False},
+# {"name": "limited_num", "description": {"zh": "返回的中转余票数量限制,默认为10。", "en": "Limit number of returned results (default: 10)."}, "type": "number", "required": False}
+# ]},
+# {"name": "get_train_route_stations", "description": {"zh": "查询特定列车车次在指定区间内的途径车站、到站时间、出发时间及停留时间等详细经停信息。", "en": "Query detailed stop information for a specific train within a segment."}, "parameters": [
+# {"name": "train_no", "description": {"zh": "要查询的实际车次编号 train_no,例如 '240000G10336'。", "en": "Actual train number train_no, e.g. '240000G10336'."}, "type": "string", "required": True},
+# {"name": "from_station_telecode", "description": {"zh": "出发站的 station_telecode (3位字母编码)。", "en": "station_telecode (3-letter code) of the origin station."}, "type": "string", "required": True},
+# {"name": "to_station_telecode", "description": {"zh": "到达站的 station_telecode (3位字母编码)。", "en": "station_telecode (3-letter code) of the destination station."}, "type": "string", "required": True},
+# {"name": "depart_date", "description": {"zh": "列车从出发站出发的日期 (格式: yyyy-MM-dd)。", "en": "Departure date from the origin station (format: yyyy-MM-dd)."}, "type": "string", "required": True}
+# ]}
+# ]
+# }
+
+import json
+import re
+import time
+import math
+from datetime import datetime, timedelta, timezone
+from urllib.parse import quote, unquote
+
+Error = Exception
+
+API_BASE = 'https://kyfw.12306.cn'
+WEB_URL = 'https://www.12306.cn/index/'
+LCQUERY_INIT_URL = 'https://kyfw.12306.cn/otn/lcQuery/init'
+
+LCQUERY_PATH = None
+MISSING_STATIONS = [
+ {"station_id": "@cdd", "station_name": "成 都东", "station_code": "WEI", "station_pinyin": "chengdudong", "station_short": "cdd", "station_index": "", "code": "1707", "city": "成都", "r1": "", "r2": ""},
+]
+
+STATIONS = None
+CITY_STATIONS = None
+CITY_CODES = None
+NAME_STATIONS = None
+
+SEAT_SHORT_TYPES = {"swz": "商务座", "tz": "特等座", "zy": "一等座", "ze": "二等座", "gr": "高软卧", "srrb": "动卧", "rw": "软卧", "yw": "硬卧", "rz": "软座", "yz": "硬座", "wz": "无座", "qt": "其他", "gg": "", "yb": ""}
+SEAT_TYPES = {
+ '9': {"name": "商务座", "short": "swz"}, 'P': {"name": "特等座", "short": "tz"}, 'M': {"name": "一等座", "short": "zy"}, 'D': {"name": "优选一等座", "short": "zy"}, 'O': {"name": "二等座", "short": "ze"}, 'S': {"name": "二等包座", "short": "ze"}, '6': {"name": "高级软卧", "short": "gr"}, 'A': {"name": "高级动卧", "short": "gr"}, '4': {"name": "软卧", "short": "rw"}, 'I': {"name": "一等卧", "short": "rw"}, 'F': {"name": "动卧", "short": "rw"}, '3': {"name": "硬卧", "short": "yw"}, 'J': {"name": "二等卧", "short": "yw"}, '2': {"name": "软座", "short": "rz"}, '1': {"name": "硬座", "short": "yz"}, 'W': {"name": "无座", "short": "wz"}, 'WZ': {"name": "无座", "short": "wz"}, 'H': {"name": "其他", "short": "qt"},
+}
+DW_FLAGS = ['智能动车组', '复兴号', '静音车厢', '温馨动卧', '动感号', '支持选铺', '老年优惠']
+
+TicketDataKeys = [
+ 'secret_Sstr', 'button_text_info', 'train_no', 'station_train_code', 'start_station_telecode',
+ 'end_station_telecode', 'from_station_telecode', 'to_station_telecode', 'start_time', 'arrive_time',
+ 'lishi', 'canWebBuy', 'yp_info', 'start_train_date', 'train_seat_feature',
+ 'location_code', 'from_station_no', 'to_station_no', 'is_support_card', 'controlled_train_flag',
+ 'gg_num', 'gr_num', 'qt_num', 'rw_num', 'rz_num',
+ 'tz_num', 'wz_num', 'yb_num', 'yw_num', 'yz_num',
+ 'ze_num', 'zy_num', 'swz_num', 'srrb_num', 'yp_ex',
+ 'seat_types', 'exchange_train_flag', 'houbu_train_flag', 'houbu_seat_limit', 'yp_info_new',
+ '40', '41', '42', '43', '44',
+ '45', 'dw_flag', '47', 'stopcheckTime', 'country_flag',
+ 'local_arrive_time', 'local_start_time', '52', 'bed_level_info', 'seat_discount_info',
+ 'sale_time', '56',
+]
+
+StationDataKeys = [
+ 'station_id', 'station_name', 'station_code', 'station_pinyin', 'station_short',
+ 'station_index', 'code', 'city', 'r1', 'r2',
+]
+
+client = OkHttp.newClient()
+initPromise = None
+
+
+def formatDate(date):
+ year = date.year
+ month = str(date.month).zfill(2)
+ day = str(date.day).zfill(2)
+ return f"{year}-{month}-{day}"
+
+
+def parseDate(dateStr):
+ year = int(dateStr[0:4])
+ month = int(dateStr[4:6])
+ day = int(dateStr[6:8])
+ return datetime(year, month, day, tzinfo=timezone.utc)
+
+
+def getCurrentShanghaiDate():
+ now = datetime.now(timezone.utc)
+ return now + timedelta(hours=8)
+
+
+def checkDate(dateStr):
+ todayInShanghai = getCurrentShanghaiDate()
+ todayInShanghai = todayInShanghai.replace(hour=0, minute=0, second=0, microsecond=0)
+ parts = [int(p) for p in dateStr.split('-')]
+ inputDate = datetime(parts[0], parts[1], parts[2], tzinfo=timezone.utc)
+ return inputDate >= todayInShanghai
+
+
+def parseCookies(cookies):
+ cookieRecord = {}
+ if not cookies:
+ return cookieRecord
+ for cookie in cookies:
+ keyValuePart = cookie.split(';')[0]
+ if '=' in keyValuePart:
+ key, value = keyValuePart.split('=', 1)
+ if key and value:
+ cookieRecord[key.strip()] = value.strip()
+ return cookieRecord
+
+
+def formatCookies(cookies):
+ return '; '.join([f"{k}={v}" for k, v in cookies.items()])
+
+
+async def getCookie():
+ url = f"{API_BASE}/otn/leftTicket/init"
+ try:
+ response = await client.newRequest().url(url).build().execute()
+ cookieHeader = None
+ if response.headers:
+ cookieHeader = response.headers.get('set-cookie') or response.headers.get('Set-Cookie')
+ if cookieHeader:
+ parsed = parseCookies(cookieHeader if isinstance(cookieHeader, list) else [cookieHeader])
+ if len(parsed) > 0:
+ return parsed
+ return {}
+ except Exception as error:
+ console.error('Error getting 12306 cookie:', error)
+ return None
+
+
+async def make12306Request(url, params=None, headers=None):
+ if params is None:
+ params = {}
+ if headers is None:
+ headers = {}
+ queryString = '&'.join([f"{quote(k)}={quote(str(v))}" for k, v in params.items()])
+ fullUrl = f"{url}?{queryString}" if queryString else url
+ try:
+ finalHeaders = dict(headers)
+ if finalHeaders.get('Cookie') == '':
+ del finalHeaders['Cookie']
+ request = client.newRequest().url(fullUrl).method("GET").headers(finalHeaders)
+ response = await request.build().execute()
+ if not response.isSuccessful():
+ raise Error(f"HTTP error! status: {response.statusCode}")
+ return json.loads(response.content)
+ except Exception as error:
+ console.error(f"Error making 12306 request to {fullUrl}:", error)
+ return None
+
+
+async def make12306RequestHtml(url):
+ try:
+ request = client.newRequest().url(url).method("GET")
+ response = await request.build().execute()
+ if not response.isSuccessful():
+ raise Error(f"HTTP error! status: {response.statusCode}")
+ return response.content
+ except Exception as error:
+ console.error(f"Error fetching HTML from {url}:", error)
+ return None
+
+
+def parseTicketsData(rawData):
+ result = []
+ for item in rawData:
+ values = item.split('|')
+ entry = {}
+ for index, key in enumerate(TicketDataKeys):
+ entry[key] = values[index] if index < len(values) else ""
+ result.append(entry)
+ return result
+
+
+def extractPrices(yp_info, seat_discount_info, ticketData):
+ PRICE_STR_LENGTH = 10
+ DISCOUNT_STR_LENGTH = 5
+ prices = []
+ discounts = {}
+ for i in range(int(len(seat_discount_info) / DISCOUNT_STR_LENGTH)):
+ discount_str = seat_discount_info[i * DISCOUNT_STR_LENGTH:(i + 1) * DISCOUNT_STR_LENGTH]
+ if len(discount_str) > 0:
+ discounts[discount_str[0]] = int(discount_str[1:])
+
+ for i in range(int(len(yp_info) / PRICE_STR_LENGTH)):
+ price_str = yp_info[i * PRICE_STR_LENGTH:(i + 1) * PRICE_STR_LENGTH]
+ if len(price_str) < 10:
+ continue
+ if int(price_str[6:10]) >= 3000:
+ seat_type_code = 'W'
+ elif price_str[0] not in SEAT_TYPES:
+ seat_type_code = 'H'
+ else:
+ seat_type_code = price_str[0]
+ seat_type = SEAT_TYPES.get(seat_type_code, {"name": "其他", "short": "qt"})
+ price = int(price_str[1:6]) / 10
+ discount = discounts.get(seat_type_code)
+ prices.append({
+ "seat_name": seat_type["name"],
+ "short": seat_type["short"],
+ "seat_type_code": seat_type_code,
+ "num": ticketData.get(f"{seat_type['short']}_num", ""),
+ "price": price,
+ "discount": discount,
+ })
+ return prices
+
+
+def extractDWFlags(dw_flag_str):
+ dwFlagList = dw_flag_str.split('#')
+ result = []
+ if len(dwFlagList) > 0 and '5' == dwFlagList[0]:
+ result.append(DW_FLAGS[0])
+ if len(dwFlagList) > 1 and '1' == dwFlagList[1]:
+ result.append(DW_FLAGS[1])
+ if len(dwFlagList) > 2:
+ if dwFlagList[2][:1] == 'Q':
+ result.append(DW_FLAGS[2])
+ elif dwFlagList[2][:1] == 'R':
+ result.append(DW_FLAGS[3])
+ if len(dwFlagList) > 5 and 'D' == dwFlagList[5]:
+ result.append(DW_FLAGS[4])
+ if len(dwFlagList) > 6 and 'z' != dwFlagList[6]:
+ result.append(DW_FLAGS[5])
+ if len(dwFlagList) > 7 and 'z' != dwFlagList[7]:
+ result.append(DW_FLAGS[6])
+ return result
+
+
+def parseTicketsInfo(ticketsData, map_):
+ result = []
+ for ticket in ticketsData:
+ prices = extractPrices(ticket.get("yp_info_new", ""), ticket.get("seat_discount_info", ""), ticket)
+ dw_flag = extractDWFlags(ticket.get("dw_flag", ""))
+ startDate = parseDate(ticket.get("start_train_date", "20000101"))
+ startParts = [int(x) for x in ticket.get("start_time", "00:00").split(':')]
+ durationParts = [int(x) for x in ticket.get("lishi", "00:00").split(':')]
+ startHours, startMinutes = startParts[0], startParts[1]
+ durationHours, durationMinutes = durationParts[0], durationParts[1]
+ arriveDate = startDate + timedelta(hours=startHours + durationHours, minutes=startMinutes + durationMinutes)
+ result.append({
+ "train_no": ticket.get("train_no", ""),
+ "start_date": formatDate(startDate),
+ "arrive_date": formatDate(arriveDate),
+ "start_train_code": ticket.get("station_train_code", ""),
+ "start_time": ticket.get("start_time", ""),
+ "arrive_time": ticket.get("arrive_time", ""),
+ "lishi": ticket.get("lishi", ""),
+ "from_station": map_.get(ticket.get("from_station_telecode", ""), ""),
+ "to_station": map_.get(ticket.get("to_station_telecode", ""), ""),
+ "from_station_telecode": ticket.get("from_station_telecode", ""),
+ "to_station_telecode": ticket.get("to_station_telecode", ""),
+ "prices": prices,
+ "dw_flag": dw_flag,
+ })
+ return result
+
+
+def formatTicketStatus(num):
+ if num and re.match(r'^\d+$', str(num)):
+ count = int(num)
+ return '无票' if count == 0 else f'剩余{count}张票'
+ if num in ('有', '充足'):
+ return '有票'
+ if num in ('无', '--', ''):
+ return '无票'
+ if num == '候补':
+ return '无票需候补'
+ return f'{num}票'
+
+
+def formatTicketsInfo(ticketsInfo):
+ if len(ticketsInfo) == 0:
+ return '没有查询到相关车次信息'
+ result = '车次 | 出发站 -> 到达站 | 出发时间 -> 到达时间 | 历时\n'
+ for ticketInfo in ticketsInfo:
+ infoStr = f"{ticketInfo['start_train_code']}(实际车次train_no: {ticketInfo['train_no']}) {ticketInfo['from_station']}(telecode: {ticketInfo['from_station_telecode']}) -> {ticketInfo['to_station']}(telecode: {ticketInfo['to_station_telecode']}) {ticketInfo['start_time']} -> {ticketInfo['arrive_time']} 历时:{ticketInfo['lishi']}"
+ for price in ticketInfo["prices"]:
+ infoStr += f"\n- {price['seat_name']}: {formatTicketStatus(price['num'])} {price['price']}元"
+ result += f"{infoStr}\n"
+ return result
+
+
+def trainFilterG(t):
+ return t["start_train_code"].startswith('G') or t["start_train_code"].startswith('C')
+
+
+def trainFilterD(t):
+ return t["start_train_code"].startswith('D')
+
+
+def trainFilterZ(t):
+ return t["start_train_code"].startswith('Z')
+
+
+def trainFilterT(t):
+ return t["start_train_code"].startswith('T')
+
+
+def trainFilterK(t):
+ return t["start_train_code"].startswith('K')
+
+
+def trainFilterO(t):
+ return not re.match(r'^[GDZTK]', t["start_train_code"])
+
+
+def trainFilterF(t):
+ if "dw_flag" in t:
+ return '复兴号' in t["dw_flag"]
+ return '复兴号' in t["ticketList"][0]["dw_flag"]
+
+
+def trainFilterS(t):
+ if "dw_flag" in t:
+ return '智能动车组' in t["dw_flag"]
+ return '智能动车组' in t["ticketList"][0]["dw_flag"]
+
+
+TRAIN_FILTERS = {'G': trainFilterG, 'D': trainFilterD, 'Z': trainFilterZ, 'T': trainFilterT, 'K': trainFilterK, 'O': trainFilterO, 'F': trainFilterF, 'S': trainFilterS}
+
+
+def timeCompareStartTime(a, b):
+ da = datetime.strptime(f"{a['start_date']} {a['start_time']}", "%Y-%m-%d %H:%M")
+ db = datetime.strptime(f"{b['start_date']} {b['start_time']}", "%Y-%m-%d %H:%M")
+ return (da - db).total_seconds() * 1000
+
+
+def timeCompareArriveTime(a, b):
+ da = datetime.strptime(f"{a['arrive_date']} {a['arrive_time']}", "%Y-%m-%d %H:%M")
+ db = datetime.strptime(f"{b['arrive_date']} {b['arrive_time']}", "%Y-%m-%d %H:%M")
+ return (da - db).total_seconds() * 1000
+
+
+def timeCompareDuration(a, b):
+ hA, mA = [int(x) for x in a["lishi"].split(':')]
+ hB, mB = [int(x) for x in b["lishi"].split(':')]
+ return (hA * 60 + mA) - (hB * 60 + mB)
+
+
+TIME_COMPARETOR = {"startTime": timeCompareStartTime, "arriveTime": timeCompareArriveTime, "duration": timeCompareDuration}
+
+
+def filterTicketsInfo(ticketsInfo, trainFilterFlags, sortFlag='', sortReverse=False, limitedNum=0):
+ if trainFilterFlags:
+ flags = list(trainFilterFlags)
+ result = [t for t in ticketsInfo if any(TRAIN_FILTERS.get(f, lambda x: False)(t) for f in flags)]
+ else:
+ result = list(ticketsInfo)
+ if sortFlag in TIME_COMPARETOR:
+ result.sort(key=lambda x: x)
+ # Python sort with custom comparator
+ import functools
+ result = sorted(result, key=functools.cmp_to_key(TIME_COMPARETOR[sortFlag]))
+ if sortReverse:
+ result.reverse()
+ return result[:limitedNum] if limitedNum > 0 else result
+
+
+def parseRouteStationsInfo(routeStationsData):
+ result = []
+ for index, routeStationData in enumerate(routeStationsData):
+ result.append({
+ "arrive_time": routeStationData.get("start_time") if index == 0 else routeStationData.get("arrive_time", ""),
+ "station_name": routeStationData.get("station_name", ""),
+ "stopover_time": routeStationData.get("stopover_time", ""),
+ "station_no": int(routeStationData.get("station_no", 0)),
+ })
+ return result
+
+
+def parseInterlinesTicketInfo(interlineTicketsData):
+ result = []
+ for ticket in interlineTicketsData:
+ prices = extractPrices(ticket.get("yp_info", ""), ticket.get("seat_discount_info", ""), ticket)
+ startDate = parseDate(ticket.get("start_train_date", "20000101"))
+ startParts = [int(x) for x in ticket.get("start_time", "00:00").split(':')]
+ durationParts = [int(x) for x in ticket.get("lishi", "00:00").split(':')]
+ startHours, startMinutes = startParts[0], startParts[1]
+ durationHours, durationMinutes = durationParts[0], durationParts[1]
+ arriveDate = startDate + timedelta(hours=startHours + durationHours, minutes=startMinutes + durationMinutes)
+ result.append({
+ "train_no": ticket.get("train_no", ""),
+ "start_train_code": ticket.get("station_train_code", ""),
+ "start_date": formatDate(startDate),
+ "arrive_date": formatDate(arriveDate),
+ "start_time": ticket.get("start_time", ""),
+ "arrive_time": ticket.get("arrive_time", ""),
+ "lishi": ticket.get("lishi", ""),
+ "from_station": ticket.get("from_station_name", ""),
+ "to_station": ticket.get("to_station_name", ""),
+ "from_station_telecode": ticket.get("from_station_telecode", ""),
+ "to_station_telecode": ticket.get("to_station_telecode", ""),
+ "prices": prices,
+ "dw_flag": extractDWFlags(ticket.get("dw_flag", "")),
+ })
+ return result
+
+
+def extractLishi(all_lishi):
+ match = re.search(r'(?:(\d+)小时)?(\d+)分钟', all_lishi)
+ if not match:
+ return '00:00'
+ hours = (match.group(1) or '0').zfill(2)
+ minutes = (match.group(2) or '0').zfill(2)
+ return f"{hours}:{minutes}"
+
+
+def parseInterlinesInfo(interlineData):
+ result = []
+ for ticket in interlineData:
+ fullList = ticket.get("fullList") or []
+ result.append({
+ "lishi": extractLishi(ticket.get("all_lishi", "")),
+ "start_time": ticket.get("start_time", ""),
+ "start_date": ticket.get("train_date", ""),
+ "middle_date": ticket.get("middle_date", ""),
+ "arrive_date": ticket.get("arrive_date", ""),
+ "arrive_time": ticket.get("arrive_time", ""),
+ "from_station_code": ticket.get("from_station_code", ""),
+ "from_station_name": ticket.get("from_station_name", ""),
+ "middle_station_code": ticket.get("middle_station_code", ""),
+ "middle_station_name": ticket.get("middle_station_name", ""),
+ "end_station_code": ticket.get("end_station_code", ""),
+ "end_station_name": ticket.get("end_station_name", ""),
+ "start_train_code": fullList[0].get("station_train_code", "") if fullList else "",
+ "first_train_no": ticket.get("first_train_no", ""),
+ "second_train_no": ticket.get("second_train_no", ""),
+ "train_count": ticket.get("train_count", 0),
+ "ticketList": parseInterlinesTicketInfo(fullList),
+ "same_station": ticket.get("same_station") == '0',
+ "same_train": ticket.get("same_train") == 'Y',
+ "wait_time": ticket.get("wait_time", ""),
+ })
+ return result
+
+
+def formatInterlinesInfo(interlinesInfo):
+ if len(interlinesInfo) == 0:
+ return '没有查询到相关的中转车次信息'
+ result = '出发时间 -> 到达时间 | 出发车站 -> 中转车站 -> 到达车站 | 换乘标志 | 换乘等待时间 | 总历时\n\n'
+ for info in interlinesInfo:
+ result += f"{info['start_date']} {info['start_time']} -> {info['arrive_date']} {info['arrive_time']} | "
+ result += f"{info['from_station_name']} -> {info['middle_station_name']} -> {info['end_station_name']} | "
+ transfer = '同车换乘' if info['same_train'] else ('同站换乘' if info['same_station'] else '换站换乘')
+ result += f"{transfer} | {info['wait_time']} | {info['lishi']}\n\n"
+ result += '\t' + formatTicketsInfo(info["ticketList"]).replace('\n', '\n\t') + '\n'
+ return result
+
+
+def parseStationsData(rawData):
+ result = {}
+ dataArray = rawData.split('|')
+ for i in range(0, len(dataArray), 10):
+ group = dataArray[i:i + 10]
+ if len(group) < 10:
+ continue
+ station = {}
+ for index, key in enumerate(StationDataKeys):
+ station[key] = group[index]
+ if station.get("station_code"):
+ result[station["station_code"]] = station
+ return result
+
+
+async def getStationsInternal():
+ stationNameJSUrl = "https://kyfw.12306.cn/otn/resources/js/framework/station_name.js"
+ stationNameJS = await make12306RequestHtml(stationNameJSUrl)
+ if not stationNameJS:
+ raise Error('Error: get station name js file content failed.')
+ rawDataMatch = re.search(r"var station_names\s*=\s*'(.*?)';", stationNameJS)
+ if not rawDataMatch:
+ raise Error('Error: could not find station data in JS file.')
+ rawData = rawDataMatch.group(1)
+ stationsData = parseStationsData(rawData)
+ for station in MISSING_STATIONS:
+ if station["station_code"] not in stationsData:
+ stationsData[station["station_code"]] = station
+ return stationsData
+
+
+async def getLCQueryPath():
+ html = await make12306RequestHtml(LCQUERY_INIT_URL)
+ if html is None:
+ raise Error('Error: get 12306 web page for LCQuery path failed.')
+ match = re.search(r"var lc_search_url = '(.+?)'", html)
+ if match is None:
+ raise Error('Error: get LCQuery path failed.')
+ return match.group(1)
+
+
+async def init():
+ global STATIONS, CITY_STATIONS, CITY_CODES, NAME_STATIONS, LCQUERY_PATH, initPromise
+ if initPromise is not None:
+ return
+ if STATIONS is not None:
+ return
+ try:
+ STATIONS = await getStationsInternal()
+ LCQUERY_PATH = await getLCQueryPath()
+ CITY_STATIONS = {}
+ for station in STATIONS.values():
+ city = station["city"]
+ if city not in CITY_STATIONS:
+ CITY_STATIONS[city] = []
+ CITY_STATIONS[city].append({"station_code": station["station_code"], "station_name": station["station_name"]})
+ CITY_CODES = {}
+ for city, stations in CITY_STATIONS.items():
+ for station in stations:
+ if station["station_name"] == city:
+ CITY_CODES[city] = station
+ break
+ NAME_STATIONS = {}
+ for station in STATIONS.values():
+ NAME_STATIONS[station["station_name"]] = {"station_code": station["station_code"], "station_name": station["station_name"]}
+ except Exception as e:
+ initPromise = None
+ raise e
+
+
+async def get_current_date(params):
+ now = getCurrentShanghaiDate()
+ return formatDate(now)
+
+
+async def get_stations_code_in_city(params):
+ await init()
+ if params["city"] not in CITY_STATIONS:
+ raise Error('City not found.')
+ return CITY_STATIONS[params["city"]]
+
+
+async def get_station_code_of_citys(params):
+ await init()
+ result = {}
+ for city in params["citys"].split('|'):
+ if city not in CITY_CODES:
+ result[city] = {"error": "未检索到城市。"}
+ else:
+ result[city] = CITY_CODES[city]
+ return result
+
+
+async def get_station_code_by_names(params):
+ await init()
+ result = {}
+ for stationName in params["station_names"].split('|'):
+ sn = stationName[:-1] if stationName.endswith('站') else stationName
+ if sn not in NAME_STATIONS:
+ result[stationName] = {"error": "未检索到车站。"}
+ else:
+ result[stationName] = NAME_STATIONS[sn]
+ return result
+
+
+async def get_station_by_telecode(params):
+ await init()
+ if params["station_telecode"] not in STATIONS:
+ raise Error('Station not found.')
+ return STATIONS[params["station_telecode"]]
+
+
+async def get_tickets(params):
+ await init()
+ if not checkDate(params["date"]):
+ raise Error('The date cannot be earlier than today.')
+ if params["from_station"] not in STATIONS or params["to_station"] not in STATIONS:
+ raise Error('Station not found.')
+ queryParams = {
+ 'leftTicketDTO.train_date': params["date"],
+ 'leftTicketDTO.from_station': params["from_station"],
+ 'leftTicketDTO.to_station': params["to_station"],
+ 'purpose_codes': 'ADULT',
+ }
+ queryUrl = f"{API_BASE}/otn/leftTicket/query"
+ cookies = await getCookie()
+ if not cookies:
+ raise Error('Get cookie failed. Check your network.')
+ response = await make12306Request(queryUrl, queryParams, {"Cookie": formatCookies(cookies)})
+ if not response or not response.get("data") or not response["data"].get("result"):
+ raise Error('Get tickets data failed.')
+ ticketsData = parseTicketsData(response["data"]["result"])
+ ticketsInfo = parseTicketsInfo(ticketsData, response["data"]["map"])
+ filteredTicketsInfo = filterTicketsInfo(ticketsInfo, params.get("train_filter_flags") or '', params.get("sort_flag", ''), params.get("sort_reverse", False), params.get("limited_num", 0))
+ return formatTicketsInfo(filteredTicketsInfo)
+
+
+async def get_interline_tickets(params):
+ await init()
+ if not checkDate(params["date"]):
+ raise Error('The date cannot be earlier than today.')
+ if params["from_station"] not in STATIONS or params["to_station"] not in STATIONS:
+ raise Error('Station not found.')
+ cookies = await getCookie()
+ if not cookies:
+ raise Error('Get cookie failed. Check your network.')
+ limited_num = params.get("limited_num") or 10
+ interlineData = []
+ queryParams = {
+ 'train_date': params["date"],
+ 'from_station_telecode': params["from_station"],
+ 'to_station_telecode': params["to_station"],
+ 'middle_station': params.get("middle_station") or '',
+ 'result_index': '0',
+ 'can_query': 'Y',
+ 'isShowWZ': 'Y' if params.get("show_wz") else 'N',
+ 'purpose_codes': '00',
+ 'channel': 'E',
+ }
+ while len(interlineData) < limited_num:
+ response = await make12306Request(f"{API_BASE}{LCQUERY_PATH}", queryParams, {"Cookie": formatCookies(cookies)})
+ if not response:
+ raise Error('Request interline tickets data failed.')
+ if isinstance(response.get("data"), str):
+ return f"很抱歉,未查到相关的列车余票。({response.get('errorMsg')})"
+ middleList = response["data"].get("middleList") or []
+ interlineData.extend(middleList)
+ if response["data"].get("can_query") == 'N' or not middleList:
+ break
+ queryParams['result_index'] = str(response["data"]["result_index"])
+ interlineTicketsInfo = parseInterlinesInfo(interlineData)
+ filtered = filterTicketsInfo(interlineTicketsInfo, params.get("train_filter_flags") or '', params.get("sort_flag", ''), params.get("sort_reverse", False), limited_num)
+ return formatInterlinesInfo(filtered)
+
+
+async def get_train_route_stations(params):
+ await init()
+ queryParams = {
+ 'train_no': params["train_no"],
+ 'from_station_telecode': params["from_station_telecode"],
+ 'to_station_telecode': params["to_station_telecode"],
+ 'depart_date': params["depart_date"],
+ }
+ queryUrl = f"{API_BASE}/otn/czxx/queryByTrainNo"
+ cookies = await getCookie()
+ if not cookies:
+ raise Error('Get cookie failed.')
+ response = await make12306Request(queryUrl, queryParams, {"Cookie": formatCookies(cookies)})
+ if not response or not response.get("data") or not response["data"].get("data"):
+ raise Error('Get train route stations failed.')
+ routeStationsInfo = parseRouteStationsInfo(response["data"]["data"])
+ if len(routeStationsInfo) == 0:
+ return '未查询到相关车次信息。'
+ return routeStationsInfo
+
+
+async def wrap(func, params, successMessage, failMessage):
+ try:
+ result = await func(params)
+ complete({"success": True, "message": successMessage, "data": result})
+ except Exception as error:
+ console.error(f"Function {func.__name__} failed! Error: {str(error)}")
+ import traceback
+ complete({"success": False, "message": f"{failMessage}: {str(error)}", "error_stack": traceback.format_exc()})
+
+
+async def main():
+ console.log("--- 开始测试 12306 工具包 ---")
+ try:
+ await init()
+ console.log("\n[1/8] 测试 get_current_date...")
+ dateResult = await get_current_date({})
+ console.log("测试结果:", json.dumps(dateResult, indent=2, ensure_ascii=False))
+ testDate = dateResult
+ console.log("\n[2/8] 测试 get_stations_code_in_city (北京)...")
+ cityStations = await get_stations_code_in_city({"city": "北京"})
+ console.log("测试结果:", json.dumps(cityStations, indent=2, ensure_ascii=False))
+ console.log("\n[3/8] 测试 get_station_code_of_citys (北京|上海)...")
+ cityCodesResult = await get_station_code_of_citys({"citys": "北京|上海"})
+ console.log("测试结果:", json.dumps(cityCodesResult, indent=2, ensure_ascii=False))
+ console.log("\n--- 12306 工具包测试完成 ---")
+ complete({"success": True, "message": "所有测试已成功或已记录错误。"})
+ except Exception as e:
+ console.error("测试主函数出现错误:", str(e))
+ complete({"success": False, "message": f"测试失败: {str(e)}"})
+
+
+async def _get_current_date(params):
+ await wrap(get_current_date, params, '获取当前日期成功', '获取当前日期失败')
+
+
+async def _get_stations_code_in_city(params):
+ await wrap(get_stations_code_in_city, params, '查询成功', '查询失败')
+
+
+async def _get_station_code_of_citys(params):
+ await wrap(get_station_code_of_citys, params, '查询成功', '查询失败')
+
+
+async def _get_station_code_by_names(params):
+ await wrap(get_station_code_by_names, params, '查询成功', '查询失败')
+
+
+async def _get_station_by_telecode(params):
+ await wrap(get_station_by_telecode, params, '查询成功', '查询失败')
+
+
+async def _get_tickets(params):
+ await wrap(get_tickets, params, '查询余票成功', '查询余票失败')
+
+
+async def _get_interline_tickets(params):
+ await wrap(get_interline_tickets, params, '查询中转票成功', '查询中转票失败')
+
+
+async def _get_train_route_stations(params):
+ await wrap(get_train_route_stations, params, '查询经停站成功', '查询经停站失败')
+
+
+exports.get_current_date = _get_current_date
+exports.get_stations_code_in_city = _get_stations_code_in_city
+exports.get_station_code_of_citys = _get_station_code_of_citys
+exports.get_station_code_by_names = _get_station_code_by_names
+exports.get_station_by_telecode = _get_station_by_telecode
+exports.get_tickets = _get_tickets
+exports.get_interline_tickets = _get_interline_tickets
+exports.get_train_route_stations = _get_train_route_stations
+exports.main = main
diff --git a/operit_py/ai_chat.py b/operit_py/ai_chat.py
new file mode 100644
index 0000000..a6d6cc2
--- /dev/null
+++ b/operit_py/ai_chat.py
@@ -0,0 +1,416 @@
+# METADATA
+# {
+# "name": "ai_chat",
+# "display_name": {
+# "zh": "AI 相互对话",
+# "en": "AI-to-AI Chat"
+# },
+# "description": {
+# "zh": "调用AI模型API实现AI之间智能对话互动。",
+# "en": "Call an AI model API to enable interactive conversations between AIs."
+# },
+# "env": ["AI_API_BASE_URL", "AI_API_KEY", "AI_MODEL_NAME"],
+# "category": "Chat",
+# "tools": [{
+# "name": "chat_completion",
+# "description": {
+# "zh": "发送消息给AI模型并获取回复。支持超时保护(默认30秒)。从根源上禁止分点列表输出。",
+# "en": "Send messages to an AI model and get responses. Includes timeout protection (default: 30s). Enforces non-bulleted output from the source."
+# },
+# "parameters": [
+# {"name": "messages", "description": {"zh": "消息数组或字符串", "en": "Message array or a string"}, "type": "any", "required": true},
+# {"name": "system_prompt", "description": {"zh": "系统提示词(会自动追加禁止分点指令)", "en": "System prompt (the system will automatically append non-bulleted instructions)"}, "type": "string", "required": false},
+# {"name": "temperature", "description": {"zh": "温度参数(0.0-2.0)", "en": "Temperature (0.0-2.0)"}, "type": "number", "required": false, "default": 0.7},
+# {"name": "max_tokens", "description": {"zh": "最大生成长度", "en": "Maximum generation length"}, "type": "number", "required": false},
+# {"name": "timeout", "description": {"zh": "超时时间(毫秒)", "en": "Timeout (milliseconds)"}, "type": "number", "required": false, "default": 30000}
+# ]
+# }]
+# }
+
+import asyncio
+import json
+import math
+import re
+
+
+def _is_nan(n):
+ try:
+ return isinstance(n, float) and math.isnan(n)
+ except Exception:
+ return False
+
+
+async def universalHttpRequest(url, method="POST", headers=None, body=None, responseType="json", timeout=30000):
+ if headers is None:
+ headers = {}
+
+ async def _do_request():
+ if OkHttp is None:
+ raise Exception("OkHttp 不可用")
+
+ client = OkHttp.newBuilder().connectTimeout(timeout).readTimeout(timeout).writeTimeout(timeout).build()
+
+ requestBuilder = client.newRequest().url(url).method(method.upper())
+
+ if headers and len(headers) > 0:
+ requestBuilder.headers(headers)
+
+ upperMethod = method.upper()
+ if body is not None and upperMethod not in ("GET", "HEAD"):
+ if isinstance(body, str):
+ requestBuilder.body(body, "text")
+ else:
+ requestBuilder.body(body, "json")
+
+ response = await requestBuilder.build().execute()
+ resultBody = json.loads(response.content) if responseType == "json" else response.content
+ return {
+ "status": response.statusCode,
+ "statusText": getattr(response, "statusMessage", "") or "",
+ "headers": getattr(response, "headers", None),
+ "body": resultBody,
+ }
+
+ try:
+ return await asyncio.wait_for(_do_request(), timeout=timeout / 1000)
+ except asyncio.TimeoutError:
+ raise Exception(f"请求超时:{timeout}ms")
+
+
+def cleanText(text):
+ if not text or not isinstance(text, str):
+ return str(text if text is not None else "")
+
+ cleaned = text
+
+ # 处理转义与实体
+ cleaned = re.sub(r'\|["\\]?n', '\n', cleaned)
+
+ escape_map = {"n": "\n", "r": "\r", "t": "\t", '"': '"', "'": "'", "&": "&"}
+
+ def _escape_repl(m):
+ c = m.group(1)
+ return escape_map.get(c, m.group(0))
+
+ cleaned = re.sub(r'\\\\([\\nrt"\'&])', _escape_repl, cleaned)
+ cleaned = re.sub(r'\\u([0-9A-Fa-f]{4})', lambda m: chr(int(m.group(1), 16)), cleaned)
+
+ # 一次性替换HTML实体
+ entities = {"quot": '"', "amp": "&", "lt": "<", "gt": ">", "nbsp": " ", "#39": "'", "apos": "'"}
+
+ def _entity_repl(m):
+ e = m.group(1)
+ return entities.get(e, m.group(0))
+
+ cleaned = re.sub(r"&(\w+|#\d+);", _entity_repl, cleaned)
+
+ # 清理代码块
+ cleaned = re.sub(r'```[\w-]*\s*\n([\s\S]*?)```', r'\1', cleaned)
+ cleaned = cleaned.replace('```', '')
+ cleaned = re.sub(r'`([^`]+)`', r'\1', cleaned)
+
+ # 格式化空白与分点
+ cleaned = re.sub(r'[ \t]{2,}', ' ', cleaned)
+ lines = cleaned.split('\n')
+ lines = [re.sub(r'^[\s\uFEFF\xA0\u3000\u200B-\u200D]+', '', l) for l in lines]
+ cleaned = '\n'.join(lines)
+ cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
+ cleaned = re.sub(r'^\s*[-•●]\s+|^\s*\d+\.\s+|^\s*\d+\)\s+|^\s*\([a-zA-Z]\)\s+', '', cleaned, flags=re.MULTILINE)
+
+ return cleaned or text
+
+
+def getConfig(varName, defaultValue=None):
+ try:
+ value = getEnv(varName)
+ if not value or value == f"YOUR_{varName}":
+ if defaultValue is not None:
+ return defaultValue
+ raise Exception(f"{varName} 未配置")
+ return value.strip()
+ except Exception:
+ if defaultValue is not None:
+ return defaultValue
+ raise Exception(f"{varName} 未配置")
+
+
+def getFullConfig():
+ try:
+ return {
+ "apiBaseUrl": getConfig("AI_API_BASE_URL", ""),
+ "apiKey": getConfig("AI_API_KEY", ""),
+ "modelName": getConfig("AI_MODEL_NAME", ""),
+ "timeout": 30000
+ }
+ except Exception:
+ return {"apiBaseUrl": "", "apiKey": "", "modelName": "", "timeout": 30000}
+
+
+def joinUrl(baseUrl, path):
+ normalizedBase = baseUrl if baseUrl.endswith("/") else f"{baseUrl}/"
+ normalizedPath = path[1:] if path.startswith("/") else path
+ return f"{normalizedBase}{normalizedPath}"
+
+
+async def tryEndpoints(baseUrl, payload, config, timeout):
+ if "chat/completions" in baseUrl:
+ return await universalHttpRequest(
+ baseUrl,
+ "POST",
+ {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {config['apiKey']}"
+ },
+ payload,
+ "json",
+ timeout
+ )
+
+ endpoints = ["v1/chat/completions", "chat/completions"]
+ lastError = None
+
+ for endpoint in endpoints:
+ try:
+ url = joinUrl(baseUrl, endpoint)
+ response = await universalHttpRequest(
+ url,
+ "POST",
+ {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {config['apiKey']}"
+ },
+ payload,
+ "json",
+ timeout
+ )
+ if response["status"] == 200:
+ return response
+ lastError = Exception(f"端点 {endpoint} 返回 {response['status']}")
+ except Exception as error:
+ lastError = error
+
+ raise lastError or Exception("所有端点尝试失败")
+
+
+async def chat_completion_logic(rawInput, timeout):
+ if rawInput is None:
+ raise Exception("参数错误: 输入参数不能为空")
+
+ internalParams = {
+ "messages": [],
+ "system_prompt": None,
+ "temperature": 0.7,
+ "max_tokens": None,
+ "functions": None,
+ }
+
+ # 处理输入参数
+ if isinstance(rawInput, str):
+ internalParams["messages"] = [{"role": "user", "content": rawInput}]
+ elif isinstance(rawInput, dict):
+ hasMessageField = "message" in rawInput
+ hasMessagesField = "messages" in rawInput
+
+ if hasMessagesField:
+ messagesValue = rawInput.get("messages")
+ if isinstance(messagesValue, list):
+ internalParams["messages"] = messagesValue
+ elif isinstance(messagesValue, str):
+ internalParams["messages"] = [{"role": "user", "content": messagesValue}]
+ else:
+ raise Exception("'messages' 必须是数组或字符串")
+ elif hasMessageField:
+ messageValue = rawInput.get("message")
+ if not isinstance(messageValue, str):
+ raise Exception("'message' 必须是字符串")
+ internalParams["messages"] = [{"role": "user", "content": messageValue}]
+ else:
+ raise Exception("对象必须包含 'message' 或 'messages' 字段")
+
+ internalParams["system_prompt"] = str(rawInput["system_prompt"]) if rawInput.get("system_prompt") else None
+ if rawInput.get("temperature") is not None:
+ internalParams["temperature"] = float(rawInput["temperature"])
+ if _is_nan(internalParams["temperature"]):
+ internalParams["temperature"] = 0.7
+
+ if rawInput.get("max_tokens") is not None:
+ internalParams["max_tokens"] = float(rawInput["max_tokens"])
+ if internalParams["max_tokens"] is not None and _is_nan(internalParams["max_tokens"]):
+ internalParams["max_tokens"] = None
+
+ internalParams["functions"] = rawInput.get("functions") if isinstance(rawInput.get("functions"), list) else None
+ else:
+ raise Exception(f"不支持的参数类型 '{type(rawInput).__name__}'")
+
+ # 验证messages
+ if not isinstance(internalParams["messages"], list) or len(internalParams["messages"]) == 0:
+ raise Exception("messages必须是有效数组且不为空")
+
+ for idx, msg in enumerate(internalParams["messages"]):
+ if not msg or not isinstance(msg, dict) or not msg.get("role") or not isinstance(msg.get("content"), str):
+ raise Exception(f"消息 #{idx} 格式无效")
+
+ # 获取配置
+ config = getFullConfig()
+ if not config["apiBaseUrl"] or not config["apiKey"]:
+ raise Exception("AI_API_BASE_URL 和 AI_API_KEY 必须配置")
+
+ # 构建消息数组
+ antiListInstruction = "【重要指令】你必须以连续段落的方式回答,严禁使用任何分点、列表、编号或项目符号格式。"
+ finalMessages = [
+ {
+ "role": "system",
+ "content": (internalParams["system_prompt"] + "\n\n" + antiListInstruction) if internalParams["system_prompt"] else antiListInstruction
+ }
+ ] + internalParams["messages"]
+
+ # 构建请求
+ payload = {
+ "model": str(config["modelName"] or "gpt-3.5-turbo"),
+ "messages": finalMessages,
+ "temperature": float(internalParams["temperature"])
+ }
+
+ if internalParams["max_tokens"] is not None and not _is_nan(internalParams["max_tokens"]):
+ payload["max_tokens"] = float(internalParams["max_tokens"])
+ if internalParams["functions"]:
+ payload["functions"] = internalParams["functions"]
+
+ payload = {k: v for k, v in payload.items() if v is not None}
+
+ # 发送请求
+ response = None
+ try:
+ response = await tryEndpoints(config["apiBaseUrl"], payload, config, timeout)
+ except Exception:
+ response = await universalHttpRequest(
+ config["apiBaseUrl"],
+ "POST",
+ {
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {config['apiKey']}"
+ },
+ payload,
+ "json",
+ timeout
+ )
+
+ if response["status"] != 200:
+ errorMsg = f"API请求失败: {response['status']}"
+ try:
+ errorData = json.loads(response["body"]) if isinstance(response["body"], str) else response["body"]
+ err_msg = errorData.get("error", {}).get("message") if isinstance(errorData, dict) else None
+ errorMsg += f" - {err_msg or json.dumps(errorData, ensure_ascii=False)}"
+ except Exception:
+ errorMsg += f" - {response['statusText']}"
+ raise Exception(errorMsg)
+
+ result = json.loads(response["body"]) if isinstance(response["body"], str) else response["body"]
+ choices = result.get("choices") if isinstance(result, dict) else None
+ if not choices or len(choices) == 0:
+ raise Exception("API返回格式异常")
+
+ choice = choices[0]
+ reply = {
+ "message": choice.get("message"),
+ "finish_reason": choice.get("finish_reason"),
+ "usage": result.get("usage") or None,
+ "model": result.get("model") or config["modelName"],
+ "id": result.get("id"),
+ "created": result.get("created")
+ }
+
+ message = choice.get("message") or {}
+ if message.get("function_call"):
+ reply["is_function_call"] = True
+ reply["function_name"] = message["function_call"].get("name")
+ reply["function_arguments"] = json.loads(message["function_call"].get("arguments") or "{}")
+
+ return reply
+
+
+async def chat_completion_impl(params):
+ if isinstance(params, dict):
+ normalizedParams = params
+ else:
+ normalizedParams = {"messages": str(params)}
+
+ timeout = normalizedParams.get("timeout") or 30000
+
+ try:
+ result = await asyncio.wait_for(chat_completion_logic(normalizedParams, timeout), timeout=timeout / 1000)
+ except asyncio.TimeoutError:
+ raise Exception(f"操作超时:{timeout}ms")
+
+ rawReply = (result.get("message") or {}).get("content")
+ cleanedReply = cleanText(rawReply)
+
+ return {
+ "success": True,
+ "message": "AI回复获取成功!",
+ "data": result,
+ "reply": cleanedReply,
+ "raw_reply": rawReply,
+ "usage": result.get("usage"),
+ "anti_list_applied": True
+ }
+
+
+async def wrapToolExecution(func, params=None):
+ try:
+ result = await func(params or {})
+ complete(result)
+ except Exception as error:
+ console.error(f"Tool {func.__name__} failed unexpectedly", error)
+ complete({
+ "success": False,
+ "message": f"AI对话失败: {str(error.message if hasattr(error, 'message') else error)}",
+ "error_stack": getattr(error, "__traceback__", None)
+ })
+
+
+async def chat_completion(params=None):
+ return await wrapToolExecution(chat_completion_impl, params)
+
+
+async def main(params=None):
+ if params is None:
+ params = {}
+ config = getFullConfig()
+ if not config["apiBaseUrl"] or not config["apiKey"]:
+ return {
+ "success": False,
+ "message": "AI_API_BASE_URL 和 AI_API_KEY 必须配置",
+ "config": config
+ }
+
+ return await chat_completion_impl({
+ "messages": params.get("message") or "Hello! Please respond in a continuous paragraph without any list or bullet points.",
+ "temperature": params.get("temperature") if params.get("temperature") is not None else 0.2,
+ "timeout": params.get("timeout") if params.get("timeout") is not None else 15000,
+ })
+
+
+async def _test_connection_impl():
+ return await chat_completion_impl({
+ "messages": "Hello! Please respond in a continuous paragraph without any list or bullet points.",
+ "temperature": 0.1,
+ "timeout": 10000
+ })
+
+
+async def _exported_test_connection(params=None):
+ if params is None:
+ params = {}
+ await wrapToolExecution(_test_connection_impl, {})
+
+
+async def _exported_main(params=None):
+ await wrapToolExecution(main, params)
+
+
+exports.chat_completion = chat_completion
+exports.single_message = chat_completion
+exports.test_connection = _exported_test_connection
+exports.getConfig = getFullConfig
+exports.main = _exported_main
diff --git a/operit_py/automatic_baidu_map_assistant.py b/operit_py/automatic_baidu_map_assistant.py
new file mode 100644
index 0000000..22ba169
--- /dev/null
+++ b/operit_py/automatic_baidu_map_assistant.py
@@ -0,0 +1,556 @@
+# METADATA
+# {
+# "name": "Automatic_baiduMap_assistant",
+# "display_name": {
+# "zh": "百度地图自动化助手",
+# "en": "Baidu Map Automation Assistant"
+# },
+# "description": { "zh": "高级百度地图智能助手,通过UI自动化技术实现地图交互,支持地点搜索、路线规划、周边查询等功能,为AI提供强大的地理位置服务能力。适用于出行规划、信息查询、智能问答等场景。", "en": "Advanced Baidu Maps assistant powered by UI automation. Supports place search, navigation/route planning, and nearby discovery, enabling AI-driven location services. Useful for trip planning, information lookup, and Q&A." },
+# "category": "Automatic",
+# "tools": [
+# {
+# "name": "workflow_guide",
+# "description": { "zh": "百度地图助手工具使用流程指南。要完成复杂任务,请按以下顺序组合使用工具:\n1. **搜索地点**: 使用 `search_location` 查找地点、餐馆、公司等,这是多数操作的起点。\n2. **选择地点**: 如果 `search_location` 返回多个结果,使用 `select_location_from_list` 从列表中选择一个。\n3. **开始导航**: 搜索到唯一地点或从列表中选择地点后,使用 `go_to_location` 来规划到该地的路线。\n4. **周边搜索**: 使用 `search_nearby` 查找当前位置或指定地点附近的设施。\n- **随时导航**: `navigate_to_home` 可随时返回地图主页。", "en": "Workflow guide for the Baidu Maps assistant. For complex tasks, combine tools in this order:\n1. **Search**: use `search_location` to find places/restaurants/companies (starting point for most tasks).\n2. **Select**: if `search_location` returns multiple results, use `select_location_from_list` to pick one from the list.\n3. **Navigate**: after a unique result is found or selected, use `go_to_location` to plan a route.\n4. **Nearby**: use `search_nearby` to find facilities near your current location or a specified place.\n- **Go home anytime**: use `navigate_to_home` to return to the map home screen." },
+# "parameters": [],
+# "advice": true
+# },
+# {
+# "name": "search_location",
+# "description": { "zh": "在百度地图中搜索地点、餐馆、公司等信息。", "en": "Search places, restaurants, companies, and other POIs in Baidu Maps." },
+# "parameters": [
+# { "name": "keyword", "description": { "zh": "要搜索的地点关键词", "en": "Keyword of the place to search for." }, "type": "string", "required": true }
+# ]
+# },
+# {
+# "name": "get_directions",
+# "description": { "zh": "规划从起点到终点的路线。", "en": "Plan a route from a start point to a destination." },
+# "parameters": [
+# { "name": "start_point", "description": { "zh": "路线起点,默认为'我的位置'", "en": "Route start point (default: 'My location')." }, "type": "string", "required": false },
+# { "name": "end_point", "description": { "zh": "路线终点", "en": "Route destination." }, "type": "string", "required": true },
+# { "name": "transport_mode", "description": { "zh": "交通方式:driving(驾车), transit(公交), walking(步行), cycling(骑行)", "en": "Transport mode: driving, transit, walking, cycling." }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "select_location_from_list",
+# "description": { "zh": "从 'search_location' 返回的搜索结果列表中选择一个地点。", "en": "Select a place from the result list returned by `search_location`." },
+# "parameters": [
+# { "name": "keyword", "description": { "zh": "要选择的地点名称关键词", "en": "Keyword to match the place name to select." }, "type": "string", "required": false },
+# { "name": "index", "description": { "zh": "要选择的地点在列表中的索引位置(从1开始)", "en": "Index of the item in the list (1-based)." }, "type": "number", "required": false }
+# ]
+# },
+# {
+# "name": "go_to_location",
+# "description": { "zh": "在地点详情页点击“到这去”,以开启导航或路线规划。可以选择交通方式。", "en": "On the place detail page, tap \"Go\" (到这去) to start navigation/route planning. You can optionally select a transport mode." },
+# "parameters": [
+# { "name": "transport_mode", "description": { "zh": "交通方式:新能源, 驾车, 打车, 公共交通, 代驾, 骑行, 步行, 拼车, 飞机, 火车, 客车, 摩托车", "en": "Transport mode tab label in the app (e.g., 新能源, 驾车, 打车, 公共交通, 代驾, 骑行, 步行, 拼车, 飞机, 火车, 客车, 摩托车)." }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "search_nearby",
+# "description": { "zh": "搜索中心点附近的地点信息。", "en": "Search places around a center point." },
+# "parameters": [
+# { "name": "keyword", "description": { "zh": "要搜索的周边设施关键词(如:银行, 加油站)", "en": "Nearby keyword (e.g., bank, gas station)." }, "type": "string", "required": true },
+# { "name": "center_point", "description": { "zh": "搜索的中心点,默认为'我的位置'", "en": "Center point for the search (default: 'My location')." }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "navigate_to_home",
+# "description": { "zh": "返回百度地图的主界面。", "en": "Return to the Baidu Maps home screen." },
+# "parameters": []
+# },
+# {
+# "name": "activate_voice_assistant",
+# "description": { "zh": "在主界面点击语音搜索按钮,启动“小度”语音助手。", "en": "Tap the voice search button on the home screen to launch the Xiaodu voice assistant." },
+# "parameters": []
+# }
+# ]
+# }
+
+import re
+
+# 百度地图应用包名
+BAIDU_MAP_PACKAGE = "com.baidu.BaiduMap"
+MAIN_ACTIVITY = "com.baidu.baidumaps.MapsActivity"
+
+
+# Helper to create response objects
+def createResponse(success, message, data=None):
+ if data is None:
+ data = {}
+ if isinstance(data, str):
+ return {"success": success, "message": message, "data": data}
+ result = {"success": success, "message": message}
+ result.update(data)
+ return result
+
+
+def _y_of(tv):
+ try:
+ bounds = tv.bounds
+ if not bounds:
+ return 0
+ m = re.search(r"\[\d+,(\d+)\]", bounds)
+ if m:
+ return int(m.group(1))
+ except Exception:
+ pass
+ return 0
+
+
+def _dedup_by_title(items):
+ seen = {}
+ for item in items:
+ t = item.get("title")
+ if t not in seen:
+ seen[t] = item
+ return list(seen.values())
+
+
+# Helper to find a UI element and click it
+async def findAndClick(finder):
+ element = await finder()
+ if element:
+ await element.click()
+ await Tools.System.sleep(1000)
+ return True
+ return False
+
+
+# Helper to find a UI element by text and click it
+async def findAndClickByText(text):
+ async def _finder():
+ return (await UINode.getCurrentPage()).findByText(text)
+ return await findAndClick(_finder)
+
+
+async def isOnMainPage():
+ page = await UINode.getCurrentPage()
+ # Check for the presence of key elements that indicate the main map screen.
+ drivingButton = page.findByText("驾车")
+ transitButton = page.findByText("公共交通")
+ taxiButton = page.findByText("打车")
+ hotelButton = page.findByText("订酒店")
+
+ if drivingButton and transitButton and taxiButton and hotelButton:
+ console.log("Detected Baidu Map main page by key elements.")
+ return True
+ console.log("Not on main page, key elements not found.")
+ return False
+
+
+async def ensureMain(packageName=BAIDU_MAP_PACKAGE):
+ pageInfo = await Tools.UI.getPageInfo()
+
+ # 1. If not in the correct app, start it.
+ if pageInfo.get("packageName") != packageName:
+ console.log(f"Not in the correct app. Current: {pageInfo.get('packageName')}. Starting {packageName}.")
+ await Tools.System.startApp(packageName)
+ await Tools.System.sleep(5000) # Wait for app to load
+ pageInfo = await Tools.UI.getPageInfo() # Re-check page info
+ if pageInfo.get("packageName") != packageName:
+ console.error(f"Failed to start {packageName}.")
+ return False
+
+ # 2. Check if we are already on the main page.
+ if await isOnMainPage():
+ console.log("Already on the main page.")
+ return True
+
+ # 3. If not on the main page, try pressing back up to 4 times.
+ console.log("Not on the main page. Attempting to go back.")
+ for i in range(4):
+ await Tools.UI.pressKey("KEYCODE_BACK")
+ await Tools.System.sleep(1000) # Wait for UI to settle
+
+ pageInfo = await Tools.UI.getPageInfo()
+ # If we have exited the app, stop trying.
+ if pageInfo.get("packageName") != packageName:
+ console.log("Exited the app while trying to go back.")
+ break # Exit the loop, will proceed to restart
+
+ if await isOnMainPage():
+ console.log(f"Successfully returned to main page on attempt {i + 1}.")
+ return True
+
+ # 4. If pressing back failed, perform a full app restart.
+ console.log("Pressing back failed or exited app. Performing a full app restart.")
+ await Tools.System.stopApp(packageName)
+ await Tools.System.sleep(1000)
+ await Tools.System.startApp(packageName)
+ await Tools.System.sleep(5000) # Longer wait for cold start
+
+ pageInfo = await Tools.UI.getPageInfo()
+ if pageInfo.get("packageName") != packageName:
+ console.error(f"Failed to restart {packageName}.")
+ return False
+
+ if await isOnMainPage():
+ console.log("Successfully reached main page after restart.")
+ return True
+
+ console.error(f"Failed to navigate to the main page of {packageName} even after restart.")
+ return False
+
+
+async def search_location(params):
+ keyword = params.get("keyword")
+ console.log(f"Searching for location: {keyword}")
+
+ # 确保百度地图已在前台运行
+ if not await ensureMain():
+ return createResponse(False, "无法启动或切换到百度地图")
+
+ await Tools.System.sleep(2000) # 等待首页完全加载
+
+ # 查找并点击搜索框
+ page = await UINode.getCurrentPage()
+ searchBox = page.findById('serachbox_container')
+ if not searchBox:
+ return createResponse(False, "在地图主页未找到搜索框 (ID: serachbox_container)")
+ await searchBox.click()
+ await Tools.System.sleep(300)
+ await Tools.UI.setText(keyword)
+ await Tools.System.sleep(300)
+
+ # 点击搜索按钮
+ searchButton = (await UINode.getCurrentPage()).findByText("搜索")
+ if searchButton:
+ await searchButton.click()
+
+ await Tools.System.sleep(5000) # 等待搜索结果加载
+
+ # 上拉以显示完整的结果列表,这在某些情况下是必要的
+ console.log("执行上拉手势以确保结果列表完全可见...")
+ await Tools.UI.swipe(540, 1800, 540, 900) # 从屏幕底部向上滑动
+ await Tools.System.sleep(1500) # 等待动画完成
+
+ # 检查页面是唯一结果还是列表
+ pageAfterSearch = await UINode.getCurrentPage()
+ listContainer = pageAfterSearch.findById("com.baidu.BaiduMap:id/talosListContainer")
+
+ if listContainer:
+ # 情况2: 结果列表
+ console.log("检测到列表容器,开始解析结果列表...")
+ results = await get_map_search_results()
+
+ return createResponse(True, f"搜索到 {len(results)} 个相关地点。请使用 'select_location_from_list' 选择一个。", {
+ "keyword": keyword,
+ "results": [{k: v for k, v in r.items() if k != "element"} for r in results], # 移除 element 属性
+ "result_count": len(results)
+ })
+
+ goToButton = pageAfterSearch.findByText("到这去")
+ if goToButton:
+ # 情况1: 唯一结果
+ return createResponse(True, f"已找到唯一结果 \"{keyword}\"。您现在可以使用 'go_to_location' 进行导航。", {"keyword": keyword})
+ else:
+ return createResponse(False, f"搜索 \"{keyword}\" 后既未找到结果列表,也未找到唯一结果。")
+
+
+async def get_map_search_results(desiredCount=20):
+ results = []
+ seenTitles = set()
+ maxScrolls = 1
+ lastResultCount = -1
+
+ for i in range(maxScrolls):
+ page = await UINode.getCurrentPage()
+ resultsContainer = page.findById('com.baidu.BaiduMap:id/talosListContainer')
+ if not resultsContainer:
+ console.log("未找到搜索结果容器 (ID: com.baidu.BaiduMap:id/talosListContainer)")
+ break
+
+ recyclerView = resultsContainer.findByClass("RecyclerView")
+ if not recyclerView:
+ console.log("未在结果容器中找到 RecyclerView。")
+ break
+
+ # 通过查找所有“到这去”按钮来定位每个列表项
+ goToButtons = recyclerView.findAllByText("到这去")
+
+ for button in goToButtons:
+ # 找到按钮所属的、作为RecyclerView直接子节点的那个父节点
+ item = button.closest(lambda node: getattr(node, "parent", None) is not None and node.parent.equals(recyclerView))
+ if not item:
+ continue
+
+ textViews = item.findAllByClass('TextView')
+ relevantTextViews = [tv for tv in textViews if tv.text and tv.text.strip() != '到这去' and tv.text.strip() != '']
+
+ if len(relevantTextViews) == 0:
+ continue
+
+ # 按纵坐标排序,最上面的通常是标题
+ relevantTextViews.sort(key=_y_of)
+
+ title = relevantTextViews[0].text
+ if not title or title in seenTitles:
+ continue
+ seenTitles.add(title)
+
+ # 查找包含地址特征的文本作为地址
+ addressNode = next((tv for tv in relevantTextViews if tv.text and ('省' in tv.text or '市' in tv.text or '区' in tv.text or '路' in tv.text or '号' in tv.text)), None)
+ address = addressNode.text if addressNode else "N/A"
+
+ results.append({
+ "index": len(results) + 1, # 临时索引
+ "title": title,
+ "address": address,
+ "element": item
+ })
+
+ # 滚动前去重并检查是否需要停止
+ uniqueResults = _dedup_by_title(results)
+
+ if len(uniqueResults) >= desiredCount:
+ console.log(f"已找到 {len(uniqueResults)} 条不重复结果,满足要求。")
+ break
+ if len(uniqueResults) == lastResultCount:
+ console.log("滚动未产生新结果,停止滚动。")
+ break
+ lastResultCount = len(uniqueResults)
+
+ # 向下滚动加载更多
+ await Tools.UI.swipe(540, 1800, 540, 600)
+ await Tools.System.sleep(2000) # 增加等待时间以确保加载
+
+ # 最终去重并重新编号索引
+ finalResults = _dedup_by_title(results)
+ for index, item in enumerate(finalResults):
+ item["index"] = index + 1
+
+ return finalResults[:desiredCount]
+
+
+# --- Placeholder functions for other tools ---
+async def select_location_from_list(params):
+ keyword = params.get("keyword")
+ index = params.get("index")
+
+ if not keyword and not index:
+ return createResponse(False, "请提供地点关键词或索引。")
+ console.log(f"尝试从列表中选择 - 关键词: \"{keyword}\", 索引: {index}")
+
+ seenTitles = set()
+ currentItemCount = 0
+ maxScrolls = 10
+ lastSeenCount = -1
+
+ for i in range(maxScrolls):
+ page = await UINode.getCurrentPage()
+ # In Baidu Maps, the results are often inside a RecyclerView
+ recyclerView = page.findByClass("RecyclerView")
+ if not recyclerView:
+ console.log("未找到 RecyclerView, 无法选择项目。")
+ return createResponse(False, "未找到结果列表容器 (RecyclerView)。")
+
+ goToButtons = recyclerView.findAllByText("到这去")
+
+ for button in goToButtons:
+ item = button.closest(lambda node: getattr(node, "parent", None) is not None and node.parent.equals(recyclerView))
+ if not item:
+ continue
+
+ textViews = item.findAllByClass('TextView')
+ relevantTextViews = [tv for tv in textViews if tv.text and tv.text.strip() != '到这去' and tv.text.strip() != '']
+ if len(relevantTextViews) == 0:
+ continue
+
+ relevantTextViews.sort(key=_y_of)
+
+ title = relevantTextViews[0].text
+ if not title or title in seenTitles:
+ continue
+
+ seenTitles.add(title)
+ currentItemCount += 1
+
+ shouldClick = False
+ if index:
+ if currentItemCount == index:
+ shouldClick = True
+ elif keyword:
+ if keyword in title:
+ shouldClick = True
+
+ if shouldClick:
+ await item.click()
+ await Tools.System.sleep(2000)
+ return createResponse(True, f"已成功选择地点: \"{title}\"")
+
+ if len(seenTitles) == lastSeenCount:
+ console.log("滚动未产生新结果,停止。")
+ break
+ lastSeenCount = len(seenTitles)
+
+ if index and currentItemCount >= index:
+ # If we're looking for an index and have passed it, no need to scroll more.
+ break
+
+ await Tools.UI.swipe(540, 1800, 540, 600) # Swipe up
+ await Tools.System.sleep(2000)
+
+ return createResponse(False, "在列表中未找到或无法选择指定的地点。", {"keyword": keyword, "index": index})
+
+
+async def go_to_location(params):
+ transportMode = params.get("transport_mode") or "驾车" # 默认为驾车
+ console.log("尝试点击 '到这去' 按钮...")
+ page = await UINode.getCurrentPage()
+ goToButton = page.findByText("到这去")
+ if goToButton:
+ await goToButton.click()
+ await Tools.System.sleep(3000) # 等待路线规划页面加载
+
+ if transportMode:
+ console.log(f"尝试选择交通方式: {transportMode}")
+ pageAfterClick = await UINode.getCurrentPage()
+
+ # 尝试直接点击可见的交通方式按钮
+ try:
+ modeButton = pageAfterClick.findByText(transportMode)
+ if modeButton:
+ await modeButton.click()
+ await Tools.System.sleep(1000)
+ return createResponse(True, f"已点击“到这去”,并成功切换到“{transportMode}”模式。")
+ except Exception:
+ console.log("直接点击失败,按钮可能在屏幕外。尝试滚动查找。")
+
+ # 如果未直接找到,尝试展开更多选项
+ console.log(f"未直接找到 \"{transportMode}\",尝试展开更多选项...")
+ moreModesButton = pageAfterClick.findById('route_tab_group')
+ if moreModesButton:
+ await moreModesButton.click()
+ await Tools.System.sleep(1500) # 等待展开动画
+ pageAfterClick = await UINode.getCurrentPage() # 刷新页面节点
+ modeButton = pageAfterClick.findByText(transportMode)
+ if modeButton:
+ await modeButton.click()
+ await Tools.System.sleep(1500)
+ return createResponse(True, f"已点击“到这去”,并成功切换到“{transportMode}”模式。")
+
+ return createResponse(False, f"已点击“到这去”,但无法找到交通方式“{transportMode}”。")
+
+ return createResponse(True, "已点击“到这去”,进入路线规划页面。")
+ else:
+ return createResponse(False, "在当前页面未找到“到这去”按钮。")
+
+
+async def get_directions(params):
+ return createResponse(False, "功能 'get_directions' 尚未实现。")
+
+
+async def search_nearby(params):
+ return createResponse(False, "功能 'search_nearby' 尚未实现。")
+
+
+async def navigate_to_home(params=None):
+ if params is None:
+ params = {}
+ console.log("Navigating to home screen.")
+ if await ensureMain():
+ # 在地图应用中,通常按返回键可以回到主图区
+ # 这里可以根据实际情况增加返回逻辑
+ await Tools.UI.pressKey("KEYCODE_BACK")
+ await Tools.System.sleep(500)
+ await Tools.UI.pressKey("KEYCODE_BACK")
+ return createResponse(True, "已尝试返回主界面。")
+ else:
+ return createResponse(False, "无法导航到主界面,因为无法启动百度地图。")
+
+
+async def activate_voice_assistant(params=None):
+ if params is None:
+ params = {}
+ console.log("正在激活语音助手“小度”...")
+ if not await ensureMain():
+ return createResponse(False, "无法启动或切换到百度地图主页,因此无法激活语音助手。")
+ await Tools.System.sleep(1000) # 等待UI稳定
+
+ page = await UINode.getCurrentPage()
+ voiceButton = page.findById('voice_search')
+
+ if voiceButton:
+ await voiceButton.click()
+ await Tools.System.sleep(2000) # 等待语音助手界面弹出
+ return createResponse(True, "已成功激活“小度”语音助手。")
+ else:
+ return createResponse(False, "在主界面上未找到语音搜索按钮 (ID: voice_search)。")
+
+
+async def wrapToolExecution(func, params):
+ try:
+ result = await func(params)
+ complete(dict(result))
+ except Exception as error:
+ console.error(f"Tool {func.__name__} failed unexpectedly", error)
+ complete({
+ "success": False,
+ "message": f"工具执行时发生意外错误: {str(error)}",
+ })
+
+
+async def main(params=None):
+ if params is None:
+ params = {}
+ console.log("Running main for testing...")
+ # 这是一个用于测试的函数,它会搜索一个默认位置
+ # Navigate back to home for the next test
+ await navigate_to_home({})
+ await Tools.System.sleep(3000)
+
+ console.log("--- Test Case 2: Searching for '长安大学' to test multiple results ---")
+ await search_location({"keyword": "长安大学"})
+ await Tools.System.sleep(2000)
+ await select_location_from_list({"index": 1})
+ await Tools.System.sleep(2000)
+ await go_to_location({"transport_mode": "飞机"})
+ await Tools.System.sleep(2000)
+
+
+async def _exported_search_location(params):
+ await wrapToolExecution(search_location, params)
+
+
+async def _exported_select_location_from_list(params):
+ await wrapToolExecution(select_location_from_list, params)
+
+
+async def _exported_go_to_location(params):
+ await wrapToolExecution(go_to_location, params)
+
+
+async def _exported_get_directions(params):
+ await wrapToolExecution(get_directions, params)
+
+
+async def _exported_search_nearby(params):
+ await wrapToolExecution(search_nearby, params)
+
+
+async def _exported_navigate_to_home(params=None):
+ if params is None:
+ params = {}
+ await wrapToolExecution(navigate_to_home, params)
+
+
+async def _exported_activate_voice_assistant(params=None):
+ if params is None:
+ params = {}
+ await wrapToolExecution(activate_voice_assistant, params)
+
+
+async def _exported_main(params=None):
+ if params is None:
+ params = {}
+ await wrapToolExecution(main, params)
+
+
+exports.search_location = _exported_search_location
+exports.select_location_from_list = _exported_select_location_from_list
+exports.go_to_location = _exported_go_to_location
+exports.get_directions = _exported_get_directions
+exports.search_nearby = _exported_search_nearby
+exports.navigate_to_home = _exported_navigate_to_home
+exports.activate_voice_assistant = _exported_activate_voice_assistant
+exports.main = _exported_main
diff --git a/operit_py/automatic_bilibili_assistant.py b/operit_py/automatic_bilibili_assistant.py
new file mode 100644
index 0000000..ce19c48
--- /dev/null
+++ b/operit_py/automatic_bilibili_assistant.py
@@ -0,0 +1,666 @@
+# METADATA
+# {
+# "name": "Automatic_bilibili_assistant",
+# "display_name": {"zh": "Bilibili自动化助手", "en": "Bilibili Automation Assistant"},
+# "description": {"zh": "高级B站智能助手,通过UI自动化技术实现B站应用交互,支持视频搜索播放、评论互动、用户关注等功能,为AI赋予B站社交和内容消费能力。适用于自动追番、视频推荐、社交互动等场景。", "en": "Advanced Bilibili assistant powered by UI automation. Supports video search/playback, commenting, and following uploaders, enabling AI-driven content consumption and social interaction on Bilibili."},
+# "category": "Automatic",
+# "tools": [
+# {"name": "workflow_guide", "description": {"zh": "B站助手工具使用流程指南。", "en": "Workflow guide for the Bilibili assistant."}, "parameters": [], "advice": True},
+# {"name": "search_video", "description": {"zh": "在B站搜索视频内容", "en": "Search videos on Bilibili."}, "parameters": [
+# {"name": "keyword", "description": {"zh": "搜索关键词", "en": "Search keyword."}, "type": "string", "required": True},
+# {"name": "filter_type", "description": {"zh": "搜索结果过滤类型:comprehensive(综合)、new(最新)、hot(最多播放)、danmaku(最多弹幕)", "en": "Result filter: comprehensive, new, hot (most played), danmaku (most danmaku)."}, "type": "string", "required": False}
+# ]},
+# {"name": "play_video", "description": {"zh": "播放指定的视频,可以通过标题或位置选择", "en": "Play a specific video by title keyword or by index."}, "parameters": [
+# {"name": "video_title", "description": {"zh": "要播放的视频标题关键词", "en": "Keyword to match the video title."}, "type": "string", "required": False},
+# {"name": "video_index", "description": {"zh": "要播放的视频在搜索结果中的索引位置(从1开始)", "en": "Index of the video in the search results (1-based)."}, "type": "number", "required": False}
+# ]},
+# {"name": "return_to_video_list", "description": {"zh": "从视频播放界面返回到视频列表界面", "en": "Return from the video player page back to the video list."}, "parameters": []},
+# {"name": "send_comment", "description": {"zh": "在当前视频下发送评论", "en": "Post a comment under the current video."}, "parameters": [
+# {"name": "comment_text", "description": {"zh": "要发送的评论内容", "en": "Comment text to send."}, "type": "string", "required": True}
+# ]},
+# {"name": "like_video", "description": {"zh": "给当前视频点赞", "en": "Like the current video."}, "parameters": []},
+# {"name": "collect_video", "description": {"zh": "收藏当前视频", "en": "Save (favorite) the current video."}, "parameters": [
+# {"name": "folder_name", "description": {"zh": "收藏夹名称,留空则使用默认收藏夹", "en": "Favorite folder name (leave empty to use the default folder)."}, "type": "string", "required": False}
+# ]},
+# {"name": "follow_uploader", "description": {"zh": "关注当前视频的UP主", "en": "Follow the uploader (UP) of the current video."}, "parameters": []},
+# {"name": "get_video_info", "description": {"zh": "获取当前视频的详细信息", "en": "Get detailed info of the current video."}, "parameters": []},
+# {"name": "browse_comments", "description": {"zh": "浏览当前视频的评论", "en": "Browse comments of the current video."}, "parameters": [
+# {"name": "comment_count", "description": {"zh": "获取的评论数量,默认为5条", "en": "Number of comments to fetch (default: 5)."}, "type": "number", "required": False}
+# ]},
+# {"name": "navigate_to_home", "description": {"zh": "导航到B站首页", "en": "Navigate to the Bilibili home page."}, "parameters": []},
+# {"name": "navigate_to_following", "description": {"zh": "导航到关注页面", "en": "Navigate to the Following page."}, "parameters": []},
+# {"name": "navigate_to_history", "description": {"zh": "导航到观看历史页面", "en": "Navigate to the watch history page."}, "parameters": []},
+# {"name": "toggle_fullscreen", "description": {"zh": "切换视频全屏/非全屏状态", "en": "Toggle fullscreen / windowed mode."}, "parameters": []},
+# {"name": "adjust_playback_speed", "description": {"zh": "调整视频播放速度", "en": "Adjust video playback speed."}, "parameters": [
+# {"name": "speed", "description": {"zh": "播放速度:0.5x, 0.75x, 1.0x, 1.25x, 1.5x, 2.0x", "en": "Playback speed: 0.5x, 0.75x, 1.0x, 1.25x, 1.5x, 2.0x."}, "type": "string", "required": True}
+# ]}
+# ]
+# }
+# 注意:本脚本深度依赖 Android UI 自动化 API(UINode、Tools.UI),在非 Android 平台上运行时相关功能不可用。
+
+import json
+import re
+import time
+from urllib.parse import quote, unquote
+
+Error = Exception
+
+# B站应用包名和主要Activity
+BILIBILI_PACKAGE = "tv.danmaku.bili"
+MAIN_ACTIVITY = "tv.danmaku.bili.MainActivityV2"
+VIDEO_ACTIVITY = "com.bilibili.ship.theseus.detail.UnitedBizDetailsActivity"
+
+
+def createResponse(success, message, data=None):
+ if data is None:
+ data = {}
+ if isinstance(data, str):
+ return {"success": True, "message": message, "data": data}
+ result = {"success": success, "message": message}
+ if isinstance(data, dict):
+ result.update(data)
+ return result
+
+
+async def findAndClick(finder):
+ element = await finder()
+ if element:
+ await element.click()
+ await Tools.System.sleep(1000)
+ return True
+ return False
+
+
+async def findAndClickByText(text):
+ async def _finder():
+ page = await UINode.getCurrentPage()
+ return page.findByText(text)
+ return await findAndClick(_finder)
+
+
+async def ensureMain(packageName=BILIBILI_PACKAGE):
+ pageInfo = await Tools.UI.getPageInfo()
+
+ if pageInfo.packageName == packageName and (getattr(pageInfo, "activityName", None) or "") and MAIN_ACTIVITY in (pageInfo.activityName or ""):
+ console.log("Already on the main activity.")
+ return True
+
+ if pageInfo.packageName != packageName:
+ console.log(f"Not in the correct app. Current: {pageInfo.packageName}. Starting {packageName}.")
+ await Tools.System.startApp(packageName)
+ await Tools.System.sleep(200)
+
+ console.log("Attempting to go back to main activity.")
+ for i in range(4):
+ pageInfo = await Tools.UI.getPageInfo()
+ if pageInfo.packageName == packageName and (getattr(pageInfo, "activityName", None) or "") and MAIN_ACTIVITY in (pageInfo.activityName or ""):
+ console.log(f"Successfully returned to main activity on attempt {i + 1}.")
+ return True
+ if pageInfo.packageName != packageName:
+ console.log("Exited the app while trying to go back.")
+ break
+ await Tools.UI.pressKey("KEYCODE_BACK")
+ await Tools.System.sleep(500)
+
+ console.log("Pressing back failed or exited app. Performing a full app restart.")
+ await Tools.System.stopApp(packageName)
+ await Tools.System.sleep(1000)
+ await Tools.System.startApp(packageName)
+ await Tools.System.sleep(4000)
+
+ pageInfo = await Tools.UI.getPageInfo()
+ if pageInfo.packageName == packageName and (getattr(pageInfo, "activityName", None) or "") and MAIN_ACTIVITY in (pageInfo.activityName or ""):
+ console.log("Successfully reached main activity after restart.")
+ return True
+
+ console.error(f"Failed to navigate to the main activity of {packageName}.")
+ return False
+
+
+async def navigateToSearch():
+ console.log("导航到搜索页面")
+ if not await ensureMain():
+ return False
+
+ page = await UINode.getCurrentPage()
+ searchContainer = page.findById('expand_search')
+ if searchContainer:
+ await searchContainer.click()
+ await Tools.System.sleep(1000)
+ return True
+
+ return False
+
+
+lastSearchResults = []
+
+
+async def search_video(params):
+ keyword = params.get("keyword")
+ filter_type = params.get("filter_type")
+ count = params.get("count", 20)
+
+ global lastSearchResults
+
+ console.log(f"搜索视频: {keyword}, 过滤类型: {filter_type}")
+
+ if not await navigateToSearch():
+ return createResponse(False, "无法进入搜索页面", {"keyword": keyword})
+
+ await Tools.System.sleep(1000)
+
+ searchInput = (await UINode.getCurrentPage()).findById('search_plate')
+ if searchInput:
+ await Tools.UI.setText(keyword)
+ await Tools.System.sleep(500)
+ else:
+ await Tools.UI.setText(keyword)
+ await Tools.System.sleep(500)
+
+ searchButton = (await UINode.getCurrentPage()).findById('action_search')
+ if searchButton:
+ await searchButton.click()
+ else:
+ await Tools.UI.pressKey("KEYCODE_ENTER")
+
+ await Tools.System.sleep(4000)
+
+ if filter_type:
+ await applySearchFilter(filter_type)
+
+ results = await getSearchResults(count)
+ lastSearchResults = results
+
+ return createResponse(True, f"搜索到 {len(results)} 条有效视频结果", {
+ "keyword": keyword,
+ "filter_type": filter_type,
+ "results": [{k: v for k, v in r.items() if k != 'element'} for r in results],
+ "result_count": len(results),
+ })
+
+
+async def applySearchFilter(filterType):
+ page = await UINode.getCurrentPage()
+ filterButton = page.findByText("综合")
+ if filterButton:
+ await filterButton.click()
+ await Tools.System.sleep(1000)
+
+ if filterType == "new":
+ await findAndClickByText("最新")
+ await Tools.System.sleep(1000)
+ elif filterType == "hot":
+ await findAndClickByText("最多播放")
+ await Tools.System.sleep(1000)
+ elif filterType == "danmaku":
+ await findAndClickByText("最多弹幕")
+ await Tools.System.sleep(1000)
+
+
+async def getSearchResults(desiredCount=20):
+ global lastSearchResults
+ page = await UINode.getCurrentPage()
+ results = []
+ seenTitles = set()
+
+ resultsContainer = page.findById('tv.danmaku.bili:id/recycler_view')
+ if not resultsContainer:
+ console.log("Could not find search results container.")
+ return results
+
+ lastResultCount = -1
+ maxScrolls = 10
+ scrollCount = 0
+
+ for i in range(maxScrolls):
+ page = await UINode.getCurrentPage()
+ currentContainer = page.findById('tv.danmaku.bili:id/recycler_view')
+ if not currentContainer:
+ break
+
+ for item in (getattr(currentContainer, "children", None) or []):
+ titleNode = item.findById('tv.danmaku.bili:id/title')
+ title = titleNode.text if titleNode else None
+
+ if not title or title in seenTitles:
+ continue
+
+ isAd = item.findById('tv.danmaku.bili:id/ad_tint_frame') or item.findById('tv.danmaku.bili:id/ad_tag')
+ isUser = item.findById('tv.danmaku.bili:id/user_info')
+ isGame = item.findById('tv.danmaku.bili:id/iv_background')
+ isLive = item.findByText("直播")
+
+ if isAd or isUser or isGame or isLive:
+ continue
+
+ seenTitles.add(title)
+
+ uploaderNode = item.findById('tv.danmaku.bili:id/upper') or item.findById('tv.danmaku.bili:id/upuser')
+ playCountNode = item.findById('tv.danmaku.bili:id/play_num')
+ durationNode = item.findById('tv.danmaku.bili:id/duration')
+ postTimeNode = item.findById('tv.danmaku.bili:id/post_time') or item.findById('tv.danmaku.bili:id/danmakus_num')
+
+ results.append({
+ "index": len(results) + 1,
+ "title": title,
+ "uploader": uploaderNode.text if uploaderNode else "N/A",
+ "play_count": playCountNode.text if playCountNode else "N/A",
+ "duration": durationNode.text if durationNode else "N/A",
+ "post_time": postTimeNode.text if postTimeNode else "N/A",
+ "element": item,
+ })
+
+ if len(results) >= desiredCount:
+ break
+
+ if len(results) >= desiredCount:
+ console.log(f"Reached desired count of {desiredCount}.")
+ break
+
+ if len(results) == lastResultCount:
+ console.log("Scrolling is not yielding new results. Stopping.")
+ break
+ lastResultCount = len(results)
+
+ await Tools.UI.swipe(540, 1500, 540, 800)
+ await Tools.System.sleep(1500)
+ scrollCount += 1
+
+ console.log(f"Scrolling back up {scrollCount} times.")
+ for i in range(scrollCount):
+ await Tools.UI.swipe(540, 800, 540, 1550)
+ await Tools.System.sleep(1000)
+
+ return results[:desiredCount]
+
+
+async def play_video(params):
+ video_title = params.get("video_title")
+ video_index = params.get("video_index")
+
+ if not video_title and not video_index:
+ return createResponse(False, "请提供视频标题或索引。")
+
+ console.log(f'尝试播放视频 - 标题: "{video_title}", 索引: {video_index}')
+
+ page = await UINode.getCurrentPage()
+ seenTitles = set()
+ currentVideoCount = 0
+ maxScrolls = 10
+ videoFoundAndClicked = False
+ foundVideoTitle = None
+
+ initialContainer = page.findById('tv.danmaku.bili:id/recycler_view')
+ if not initialContainer:
+ return createResponse(False, "似乎不在搜索结果页面,无法播放视频。")
+
+ lastSeenCount = -1
+ scrollCount = 0
+
+ for i in range(maxScrolls):
+ page = await UINode.getCurrentPage()
+ currentContainer = page.findById('tv.danmaku.bili:id/recycler_view')
+ if not currentContainer:
+ console.log("Could not find search results container during scroll.")
+ break
+
+ visibleItems = getattr(currentContainer, "children", None) or []
+
+ for item in visibleItems:
+ titleNode = item.findById('tv.danmaku.bili:id/title')
+ title = titleNode.text if titleNode else None
+
+ if not title or title in seenTitles:
+ continue
+
+ isAd = item.findById('tv.danmaku.bili:id/ad_tint_frame') or item.findById('tv.danmaku.bili:id/ad_tag')
+ isUser = item.findById('tv.danmaku.bili:id/user_info')
+ isGame = item.findById('tv.danmaku.bili:id/iv_background')
+ isLive = item.findByText("直播")
+
+ if isAd or isUser or isGame or isLive:
+ continue
+
+ seenTitles.add(title)
+ currentVideoCount += 1
+
+ shouldClick = False
+ if video_index:
+ if currentVideoCount == video_index:
+ shouldClick = True
+ elif video_title:
+ if video_title in title:
+ shouldClick = True
+
+ if shouldClick:
+ coverImage = item.findById('tv.danmaku.bili:id/cover')
+ if coverImage:
+ console.log("Clicking on video cover image.")
+ await coverImage.click()
+ else:
+ console.log("Cover image not found, clicking on the whole item.")
+ await item.click()
+ await Tools.System.sleep(4000)
+ videoFoundAndClicked = True
+ foundVideoTitle = title
+ break
+
+ if videoFoundAndClicked:
+ break
+
+ if len(seenTitles) == lastSeenCount:
+ console.log("Scrolling is not yielding new results. Stopping.")
+ break
+ lastSeenCount = len(seenTitles)
+
+ if video_index and currentVideoCount >= video_index:
+ break
+
+ await Tools.UI.swipe(540, 1500, 540, 800)
+ await Tools.System.sleep(1500)
+ scrollCount += 1
+
+ if not videoFoundAndClicked and scrollCount > 0:
+ console.log(f"未找到视频,滚动回去 {scrollCount} 次")
+ for i in range(scrollCount):
+ await Tools.UI.swipe(540, 800, 540, 1550)
+ await Tools.System.sleep(1000)
+
+ if videoFoundAndClicked:
+ return createResponse(True, f'成功开始播放视频: "{foundVideoTitle}"', {"video_title": video_title, "video_index": video_index})
+ else:
+ return createResponse(False, "未找到指定的视频", {"video_title": video_title, "video_index": video_index})
+
+
+async def send_comment(params):
+ commentText = params.get("comment_text") or ""
+
+ if not await switch_to_comment_activity():
+ return createResponse(False, "不在视频页面")
+
+ console.log(f"发送评论: {commentText}")
+
+ page = await UINode.getCurrentPage()
+ commentArea = page.findById("input")
+ if commentArea:
+ await commentArea.click()
+ await Tools.System.sleep(1000)
+
+ await Tools.UI.setText(commentText)
+ await Tools.System.sleep(500)
+
+ sendButton = page.findByText("发送")
+ if sendButton:
+ await sendButton.click()
+ await Tools.System.sleep(1000)
+
+ return createResponse(True, "评论发送成功", {"comment_text": commentText})
+ else:
+ return createResponse(False, "未找到评论输入框", {"comment_text": commentText})
+
+
+async def is_in_video_activity():
+ pageInfo = await Tools.UI.getPageInfo()
+ return VIDEO_ACTIVITY in (getattr(pageInfo, "activityName", "") or "")
+
+
+async def is_on_search_results_page():
+ page = await UINode.getCurrentPage()
+ filterButton = page.findByText("综合") and page.findByText("搜索")
+ return bool(filterButton)
+
+
+async def return_to_video_list(params):
+ console.log("Attempting to return to video list...")
+ if await is_in_video_activity():
+ await Tools.UI.pressKey("KEYCODE_BACK")
+ await Tools.System.sleep(2000)
+ if await is_on_search_results_page():
+ return createResponse(True, "Successfully returned to video list.")
+ else:
+ return createResponse(False, "Failed to return to video list, did not land on search results page.")
+ else:
+ if await is_on_search_results_page():
+ return createResponse(True, "Already on the video list page.")
+ return createResponse(False, "Not in a video activity, no action taken.")
+
+
+async def switch_to_comment_activity():
+ if not await is_in_video_activity():
+ return False
+ console.log("切换到评论页面")
+ await findAndClickByText("评论")
+ await Tools.System.sleep(1000)
+ return True
+
+
+async def like_video(params):
+ if not await is_in_video_activity():
+ return createResponse(False, "不在视频页面")
+ console.log("给视频点赞")
+
+ async def _finder():
+ return (await UINode.getCurrentPage()).findById("frame_like")
+
+ if await findAndClick(_finder):
+ return createResponse(True, "点赞成功")
+ else:
+ return createResponse(False, "未找到点赞按钮")
+
+
+async def collect_video(params):
+ if not await is_in_video_activity():
+ return createResponse(False, "不在视频页面")
+ folderName = params.get("folder_name")
+ console.log(f"开始收藏视频, 文件夹: {folderName}")
+
+ async def _finder():
+ return (await UINode.getCurrentPage()).findById("frame_fav")
+
+ if await findAndClick(_finder):
+ return createResponse(True, "收藏成功", {"folder_name": folderName})
+ else:
+ return createResponse(False, "未找到收藏按钮", {"folder_name": folderName})
+
+
+async def follow_uploader(params):
+ if not await is_in_video_activity():
+ return createResponse(False, "不在视频页面")
+ console.log("开始关注UP主")
+
+ async def _finder():
+ return (await UINode.getCurrentPage()).findAllById("follow")
+
+ if await findAndClick(_finder):
+ return createResponse(True, "关注成功")
+ else:
+ return createResponse(False, "未找到关注按钮或已经关注")
+
+
+async def get_video_info(params):
+ if not await is_in_video_activity():
+ return createResponse(False, "不在视频页面")
+
+ console.log("获取视频信息")
+
+ page = await UINode.getCurrentPage()
+ videoInfo = {
+ "title": "",
+ "uploader": "",
+ "play_count": "",
+ "comment_count": "",
+ "like_count": "",
+ "coin_count": "",
+ "collect_count": "",
+ "forward_count": "",
+ }
+
+ titleElement = page.findById('title')
+ if titleElement and getattr(titleElement, "text", None):
+ videoInfo["title"] = titleElement.text
+
+ likeElement = page.findById('frame_like')
+ if likeElement and getattr(likeElement, "contentDesc", None):
+ videoInfo["like_count"] = likeElement.contentDesc
+
+ collectElement = page.findById('frame_fav')
+ if collectElement and getattr(collectElement, "contentDesc", None):
+ videoInfo["collect_count"] = collectElement.contentDesc
+
+ coinElement = page.findById('frame_coin')
+ if coinElement and getattr(coinElement, "contentDesc", None):
+ videoInfo["coin_count"] = coinElement.contentDesc
+
+ forwardElement = page.findById('frame_share')
+ if forwardElement and getattr(forwardElement, "contentDesc", None):
+ videoInfo["forward_count"] = forwardElement.contentDesc
+
+ uploaderElements = page.findAllById("author_name")
+ if uploaderElements and len(uploaderElements) > 0:
+ videoInfo["uploader"] = "/".join([u.text for u in uploaderElements])
+
+ return createResponse(True, "获取视频信息成功", {"video_info": videoInfo})
+
+
+async def browse_comments(params):
+ commentCount = params.get("comment_count") or 5
+ if not await switch_to_comment_activity():
+ return createResponse(False, "不在视频页面")
+
+ console.log(f"浏览评论,获取{commentCount}条")
+
+ page = await UINode.getCurrentPage()
+
+ await Tools.UI.swipe(540, 1500, 540, 800)
+ await Tools.System.sleep(2000)
+
+ commentList = page.allTexts()
+
+ return createResponse(True, "获取评论成功", {"all_texts": commentList})
+
+
+async def navigate_to_home(params):
+ console.log("导航到首页")
+ if await ensureMain():
+ return createResponse(True, "已导航到首页")
+ else:
+ return createResponse(False, "无法导航到首页")
+
+
+async def navigate_to_following(params):
+ console.log("导航到关注页面")
+ page = await UINode.getCurrentPage()
+ followingTab = page.findByText("关注")
+ if followingTab:
+ await followingTab.click()
+ await Tools.System.sleep(2000)
+ return createResponse(True, "已导航到关注页面")
+ else:
+ return createResponse(False, "未找到关注标签")
+
+
+async def navigate_to_history(params):
+ console.log("导航到历史页面")
+ page = await UINode.getCurrentPage()
+ historyButton = page.findByText("历史")
+ if not historyButton:
+ historyButton = page.findByText("观看历史")
+
+ if historyButton:
+ await historyButton.click()
+ await Tools.System.sleep(2000)
+ return createResponse(True, "已导航到历史页面")
+ else:
+ return createResponse(False, "未找到历史按钮")
+
+
+async def toggle_fullscreen(params):
+ console.log("切换全屏状态")
+ await Tools.UI.tap(540, 960)
+ await Tools.System.sleep(1000)
+
+ page = await UINode.getCurrentPage()
+ fullscreenButton = page.findByContentDesc("全屏")
+ if not fullscreenButton:
+ fullscreenButton = page.findByText("全屏")
+ if not fullscreenButton:
+ fullscreenButton = page.findById('fullscreen_button')
+
+ if fullscreenButton:
+ await fullscreenButton.click()
+ await Tools.System.sleep(1000)
+ return createResponse(True, "全屏状态切换成功")
+ else:
+ return createResponse(False, "未找到全屏按钮")
+
+
+async def adjust_playback_speed(params):
+ speed = params.get("speed") or "1.0x"
+ console.log(f"调整播放速度为: {speed}")
+
+ await Tools.UI.tap(540, 960)
+ await Tools.System.sleep(1000)
+
+ page = await UINode.getCurrentPage()
+ speedButton = page.findByText("倍速")
+ if not speedButton:
+ speedButton = page.findByText("1.0x")
+ if not speedButton:
+ speedButton = page.findById('speed_button')
+
+ if speedButton:
+ await speedButton.click()
+ await Tools.System.sleep(1000)
+
+ targetSpeedButton = page.findByText(speed)
+ if targetSpeedButton:
+ await targetSpeedButton.click()
+ await Tools.System.sleep(1000)
+ return createResponse(True, f"播放速度已调整为 {speed}", {"speed": speed})
+ else:
+ return createResponse(False, f"未找到 {speed} 速度选项", {"speed": speed})
+ else:
+ return createResponse(False, "未找到速度设置按钮", {"speed": speed})
+
+
+async def main():
+ console.log("=== B站智能助手测试 ===")
+ console.log(await search_video({"keyword": "原神"}))
+ await play_video({"video_index": 4})
+ console.log(await get_video_info({}))
+ console.log(await browse_comments({}))
+
+
+async def wrapToolExecution(func, params):
+ try:
+ result = await func(params)
+ complete(dict(result))
+ except Exception as error:
+ console.error(f"Tool {func.__name__} failed unexpectedly", error)
+ complete({
+ "success": False,
+ "message": f"工具执行时发生意外错误: {str(error)}",
+ })
+
+
+def _make_wrapper(func):
+ async def wrapper(params):
+ await wrapToolExecution(func, params)
+ return wrapper
+
+
+exports.search_video = _make_wrapper(search_video)
+exports.play_video = _make_wrapper(play_video)
+exports.return_to_video_list = _make_wrapper(return_to_video_list)
+exports.send_comment = _make_wrapper(send_comment)
+exports.like_video = _make_wrapper(like_video)
+exports.collect_video = _make_wrapper(collect_video)
+exports.follow_uploader = _make_wrapper(follow_uploader)
+exports.get_video_info = _make_wrapper(get_video_info)
+exports.browse_comments = _make_wrapper(browse_comments)
+exports.navigate_to_home = _make_wrapper(navigate_to_home)
+exports.navigate_to_following = _make_wrapper(navigate_to_following)
+exports.navigate_to_history = _make_wrapper(navigate_to_history)
+exports.toggle_fullscreen = _make_wrapper(toggle_fullscreen)
+exports.adjust_playback_speed = _make_wrapper(adjust_playback_speed)
+exports.main = main
diff --git a/operit_py/automatic_ui_base.py b/operit_py/automatic_ui_base.py
new file mode 100644
index 0000000..e771ac5
--- /dev/null
+++ b/operit_py/automatic_ui_base.py
@@ -0,0 +1,367 @@
+# METADATA
+# {
+# "name": "Automatic_ui_base",
+# "display_name": {
+# "zh": "自动化基础工具",
+# "en": "Automation Base Tools"
+# },
+# "description": { "zh": "提供基本的UI自动化工具,能够按照用户的要求帮助操作设备屏幕(如点击、滑动、输入等)。", "en": "Basic UI automation tools to operate the device screen as requested (tap, swipe, input, etc.)." },
+# "category": "Automatic",
+# "enabledByDefault": true,
+# "tools": [
+# {
+# "name": "usage_advice",
+# "description": { "zh": "UI自动化建议:\n- 元素定位选项:\n • 列表:使用index参数(例如,“点击索引为2的列表项”)\n • 文本:使用bounds或partialMatch进行模糊匹配(例如,“点击包含‘登录’文字的按钮”)\n- 操作链:组合多个操作以完成复杂任务(例如,“获取页面信息,然后点击元素”)\n- 错误处理:如果操作失败,分析页面信息找出原因,并尝试其他方法。\n- **组合调用(推荐)**:强烈建议在一次响应中组合调用2~3个真实存在的工具,例如依次调用tap → get_page_info,或 click_element → sleep → get_page_info,一次性输出完整的操作序列。软件会自动按顺序依次执行这些工具调用。", "en": "UI automation advice:\n- Element targeting options:\n • Lists: use the index parameter (e.g., tap list item at index 2).\n • Text: use bounds or partialMatch for fuzzy matching (e.g., tap a button containing the text 'Login').\n- Action chains: combine multiple actions to complete complex tasks (e.g., get page info, then click an element).\n- Error handling: if an action fails, inspect the page info to find the cause and try alternative methods.\n- **Combined calls (recommended)**: strongly recommend combining 2~3 real tools in a single response, e.g. tap → get_page_info, or click_element → sleep → get_page_info. The system will execute these tool calls sequentially." },
+# "parameters": [],
+# "advice": true
+# },
+# {
+# "name": "app_launch",
+# "description": { "zh": "根据应用包名直接启动应用。如果未找到该包名对应的应用,则返回当前设备的软件安装列表,供你选择其他应用。", "en": "Launch an app by package name. If not found, returns the installed app list for you to choose from." },
+# "parameters": [
+# { "name": "package_name", "description": { "zh": "应用包名,例如'com.tencent.mm'", "en": "App package name, e.g. 'com.tencent.mm'." }, "type": "string", "required": true }
+# ]
+# },
+# {
+# "name": "get_page_info",
+# "description": { "zh": "获取当前UI屏幕的信息,包括完整的UI层次结构。", "en": "Get information about the current UI screen, including the full UI hierarchy." },
+# "parameters": [
+# { "name": "format", "description": { "zh": "格式,可选:'xml'或'json',默认'xml'", "en": "Format: 'xml' or 'json' (default: 'xml')." }, "type": "string", "required": false },
+# { "name": "detail", "description": { "zh": "详细程度,可选:'minimal'、'summary'或'full',默认'summary'", "en": "Detail level: 'minimal', 'summary', or 'full' (default: 'summary')." }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "get_page_screenshot_image",
+# "description": { "zh": "获取当前屏幕内容的图片版本(截图),返回保存路径。", "en": "Capture the current screen as an image (screenshot) and return the saved file path." },
+# "parameters": []
+# },
+# {
+# "name": "tap",
+# "description": { "zh": "在特定坐标模拟点击。", "en": "Simulate a tap at the specified coordinates." },
+# "parameters": [
+# { "name": "x", "description": { "zh": "X坐标", "en": "X coordinate." }, "type": "number", "required": true },
+# { "name": "y", "description": { "zh": "Y坐标", "en": "Y coordinate." }, "type": "number", "required": true }
+# ]
+# },
+# {
+# "name": "double_tap",
+# "description": { "zh": "在特定坐标模拟双击(快速连续点击两次)。", "en": "Simulate a double tap at the specified coordinates (two quick taps)." },
+# "parameters": [
+# { "name": "x", "description": { "zh": "X坐标", "en": "X coordinate." }, "type": "number", "required": true },
+# { "name": "y", "description": { "zh": "Y坐标", "en": "Y coordinate." }, "type": "number", "required": true }
+# ]
+# },
+# {
+# "name": "long_press",
+# "description": { "zh": "在特定坐标模拟长按操作。适用于呼出上下文菜单、拖拽前的按住等场景。", "en": "Simulate a long press at the specified coordinates. Useful for context menus or starting a drag." },
+# "parameters": [
+# { "name": "x", "description": { "zh": "X坐标", "en": "X coordinate." }, "type": "number", "required": true },
+# { "name": "y", "description": { "zh": "Y坐标", "en": "Y coordinate." }, "type": "number", "required": true }
+# ]
+# },
+# {
+# "name": "click_element",
+# "description": { "zh": "点击由资源ID或类名标识的元素。必须至少提供一个标识参数。", "en": "Click an element identified by resourceId or className. You must provide at least one identifier." },
+# "parameters": [
+# { "name": "resourceId", "description": { "zh": "元素资源ID", "en": "Element resourceId." }, "type": "string", "required": false },
+# { "name": "className", "description": { "zh": "元素类名", "en": "Element class name." }, "type": "string", "required": false },
+# { "name": "index", "description": { "zh": "要点击的匹配元素,从0开始计数,默认0", "en": "Index of the matched element to click (0-based, default: 0)." }, "type": "number", "required": false },
+# { "name": "partialMatch", "description": { "zh": "是否启用部分匹配,默认false", "en": "Enable partial match (default: false)." }, "type": "boolean", "required": false },
+# { "name": "bounds", "description": { "zh": "元素边界,格式为'[left,top][right,bottom]'", "en": "Element bounds in format '[left,top][right,bottom]'." }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "set_input_text",
+# "description": { "zh": "在输入字段中设置文本。", "en": "Set text in the current input field." },
+# "parameters": [
+# { "name": "text", "description": { "zh": "要输入的文本", "en": "Text to input." }, "type": "string", "required": true }
+# ]
+# },
+# {
+# "name": "press_key",
+# "description": { "zh": "模拟按键。", "en": "Simulate a key press." },
+# "parameters": [
+# { "name": "key_code", "description": { "zh": "键码,例如'KEYCODE_BACK'、'KEYCODE_HOME'等", "en": "Key code, e.g. 'KEYCODE_BACK', 'KEYCODE_HOME'." }, "type": "string", "required": true }
+# ]
+# },
+# {
+# "name": "swipe",
+# "description": { "zh": "模拟滑动手势。", "en": "Simulate a swipe gesture." },
+# "parameters": [
+# { "name": "start_x", "description": { "zh": "起始X坐标", "en": "Start X coordinate." }, "type": "number", "required": true },
+# { "name": "start_y", "description": { "zh": "起始Y坐标", "en": "Start Y coordinate." }, "type": "number", "required": true },
+# { "name": "end_x", "description": { "zh": "结束X坐标", "en": "End X coordinate." }, "type": "number", "required": true },
+# { "name": "end_y", "description": { "zh": "结束Y坐标", "en": "End Y coordinate." }, "type": "number", "required": true },
+# { "name": "duration", "description": { "zh": "持续时间,毫秒,默认300", "en": "Duration in milliseconds (default: 300)." }, "type": "number", "required": false }
+# ]
+# }
+# ]
+# }
+
+import datetime
+
+
+async def get_page_info(params):
+ result = (await UINode.getCurrentPage()).toFormattedString()
+ return {"success": True, "message": "成功获取页面信息", "data": result}
+
+
+async def get_page_screenshot_image(params):
+ try:
+ screenshotDir = OPERIT_CLEAN_ON_EXIT_DIR
+
+ # Ensure the directory exists
+ await Tools.Files.mkdir(screenshotDir, True)
+
+ timestamp = datetime.datetime.utcnow().isoformat().replace(":", "-").replace(".", "-")
+ filePath = f"{screenshotDir}/ui_screenshot_{timestamp}.png"
+
+ console.log(f"截取当前UI屏幕并保存到: {filePath}")
+
+ result = await Tools.System.shell(f"screencap -p {filePath}")
+
+ imageLink = NativeInterface.registerImageFromPath(filePath)
+
+ return {
+ "success": True,
+ "message": f"截图已保存到 {filePath}",
+ "data": {
+ "file_path": filePath,
+ "image_link": imageLink,
+ "raw_result": result,
+ },
+ }
+ except Exception as error:
+ console.error(f"获取屏幕截图失败: {str(error)}")
+ return {
+ "success": False,
+ "message": f"获取屏幕截图失败: {str(error)}",
+ }
+
+
+async def tap(params):
+ result = await Tools.UI.tap(params["x"], params["y"])
+ return {"success": True, "message": "点击操作成功", "data": result}
+
+
+async def double_tap(params):
+ first = await Tools.UI.tap(params["x"], params["y"])
+ await Tools.System.sleep(120)
+ second = await Tools.UI.tap(params["x"], params["y"])
+ return {
+ "success": True,
+ "message": "双击操作成功",
+ "data": {"first": first, "second": second},
+ }
+
+
+async def long_press(params):
+ result = await Tools.UI.longPress(params["x"], params["y"])
+ return {"success": True, "message": "长按操作成功", "data": result}
+
+
+async def click_element(params):
+ result = await Tools.UI.clickElement(params)
+ return {"success": True, "message": "点击元素操作成功", "data": result}
+
+
+async def set_input_text(params):
+ result = await Tools.UI.setText(params["text"])
+ return {"success": True, "message": "输入文本操作成功", "data": result}
+
+
+async def press_key(params):
+ result = await Tools.UI.pressKey(params["key_code"])
+ return {"success": True, "message": "按键操作成功", "data": result}
+
+
+async def swipe(params):
+ result = await Tools.UI.swipe(params["start_x"], params["start_y"], params["end_x"], params["end_y"])
+ return {"success": True, "message": "滑动操作成功", "data": result}
+
+
+async def app_launch(params):
+ if not params.get("package_name"):
+ return {"success": False, "message": "必须提供package_name参数"}
+
+ try:
+ startResult = await Tools.System.startApp(params["package_name"])
+
+ if startResult and startResult.get("success"):
+ return {
+ "success": True,
+ "message": "应用启动成功",
+ "data": {
+ "operation": startResult,
+ },
+ }
+
+ appList = await Tools.System.listApps(False)
+ return {
+ "success": False,
+ "message": "未能启动应用,可能未安装或无法找到启动入口。已返回当前安装的应用列表。",
+ "data": {
+ "operation": startResult,
+ "installed_apps": appList,
+ },
+ }
+ except Exception as error:
+ console.error(f"app_launch 执行失败: {str(error)}")
+ try:
+ appList = await Tools.System.listApps(False)
+ return {
+ "success": False,
+ "message": f"启动应用时发生错误: {str(error)}。已返回当前安装的应用列表。",
+ "data": {
+ "installed_apps": appList,
+ },
+ }
+ except Exception as listError:
+ console.error(f"获取应用列表失败: {str(listError)}")
+ return {
+ "success": False,
+ "message": f"启动应用失败且无法获取应用列表: {str(listError)}",
+ }
+
+
+async def wrapToolExecution(func, params):
+ try:
+ result = await func(params)
+ complete(result)
+ except Exception as error:
+ console.error(f"Tool {func.__name__} failed unexpectedly", error)
+ complete({
+ "success": False,
+ "message": f"工具执行时发生意外错误: {str(error)}",
+ })
+
+
+async def main():
+ console.log("=== UI Automation Tools 测试开始 ===\n")
+ results = []
+
+ try:
+ # 1. 测试 get_page_info
+ console.log("1. 测试 get_page_info...")
+ pageInfoResult = await get_page_info({})
+ results.append({"tool": "get_page_info", "result": pageInfoResult})
+ console.log("✓ get_page_info 测试完成\n")
+
+ # 2. 测试 tap (点击屏幕中心位置)
+ console.log("2. 测试 tap...")
+ tapResult = await tap({"x": 500, "y": 1000})
+ results.append({"tool": "tap", "result": tapResult})
+ console.log("✓ tap 测试完成\n")
+ await Tools.System.sleep(500)
+
+ # 3. 测试 press_key (按音量上键)
+ console.log("3. 测试 press_key...")
+ pressKeyResult = await press_key({"key_code": "KEYCODE_VOLUME_UP"})
+ results.append({"tool": "press_key", "result": pressKeyResult})
+ console.log("✓ press_key 测试完成\n")
+ await Tools.System.sleep(500)
+
+ # 4. 测试 set_input_text
+ console.log("4. 测试 set_input_text...")
+ setTextResult = await set_input_text({"text": "UI自动化测试文本"})
+ results.append({"tool": "set_input_text", "result": setTextResult})
+ console.log("✓ set_input_text 测试完成\n")
+ await Tools.System.sleep(500)
+
+ # 5. 测试 swipe (向上滑动)
+ console.log("5. 测试 swipe...")
+ swipeResult = await swipe({
+ "start_x": 500,
+ "start_y": 1500,
+ "end_x": 500,
+ "end_y": 500,
+ "duration": 300
+ })
+ results.append({"tool": "swipe", "result": swipeResult})
+ console.log("✓ swipe 测试完成\n")
+ await Tools.System.sleep(500)
+
+ # 6. 测试 click_element (尝试点击一个常见的元素)
+ console.log("6. 测试 click_element...")
+ try:
+ clickResult = await click_element({
+ "className": "android.widget.Button",
+ "index": 0
+ })
+ results.append({"tool": "click_element", "result": clickResult})
+ console.log("✓ click_element 测试完成\n")
+ except Exception as error:
+ console.log("⚠ click_element 测试失败(这可能是正常的,如果当前页面没有按钮):", str(error), "\n")
+ results.append({"tool": "click_element", "result": {"success": False, "message": str(error)}})
+
+ console.log("=== UI Automation Tools 测试完成 ===\n")
+ console.log("测试结果汇总:")
+ for i, r in enumerate(results):
+ status = "✓" if r["result"].get("success") else "✗"
+ console.log(f"{i + 1}. {status} {r['tool']}: {r['result'].get('message')}")
+
+ complete({
+ "success": True,
+ "message": "所有UI工具测试完成",
+ "data": results
+ })
+ except Exception as error:
+ console.error("测试过程中发生错误:", error)
+ complete({
+ "success": False,
+ "message": f"测试失败: {str(error)}",
+ "data": results
+ })
+
+
+async def _exported_get_page_info(params):
+ await wrapToolExecution(get_page_info, params)
+
+
+async def _exported_app_launch(params):
+ await wrapToolExecution(app_launch, params)
+
+
+async def _exported_get_page_screenshot_image(params=None):
+ await wrapToolExecution(get_page_screenshot_image, {})
+
+
+async def _exported_tap(params):
+ await wrapToolExecution(tap, params)
+
+
+async def _exported_double_tap(params):
+ await wrapToolExecution(double_tap, params)
+
+
+async def _exported_long_press(params):
+ await wrapToolExecution(long_press, params)
+
+
+async def _exported_click_element(params):
+ await wrapToolExecution(click_element, params)
+
+
+async def _exported_set_input_text(params):
+ await wrapToolExecution(set_input_text, params)
+
+
+async def _exported_press_key(params):
+ await wrapToolExecution(press_key, params)
+
+
+async def _exported_swipe(params):
+ await wrapToolExecution(swipe, params)
+
+
+exports.get_page_info = _exported_get_page_info
+exports.app_launch = _exported_app_launch
+exports.get_page_screenshot_image = _exported_get_page_screenshot_image
+exports.tap = _exported_tap
+exports.double_tap = _exported_double_tap
+exports.long_press = _exported_long_press
+exports.click_element = _exported_click_element
+exports.set_input_text = _exported_set_input_text
+exports.press_key = _exported_press_key
+exports.swipe = _exported_swipe
+exports.main = main
diff --git a/operit_py/automatic_ui_subagent.py b/operit_py/automatic_ui_subagent.py
new file mode 100644
index 0000000..6b0be0a
--- /dev/null
+++ b/operit_py/automatic_ui_subagent.py
@@ -0,0 +1,390 @@
+# METADATA
+# {
+# "name": "Automatic_ui_subagent",
+# "display_name": {"zh": "自动化AutoGLM子代理", "en": "Automated AutoGLM Sub-agent"},
+# "description": {"zh": "兼容AutoGLM,提供基于独立UI控制器模型(例如 autoglm-phone-9b)的高层UI自动化子代理工具,用于根据自然语言意图自动规划并执行点击/输入/滑动等一系列界面操作。当用户提出需要帮忙完成某个界面操作任务(例如打开应用、搜索内容、在多个页面之间完成一套步骤)时,可以调用本包由子代理自动规划和执行具体步骤。", "en": "Compatible with AutoGLM. Provides a high-level UI automation sub-agent based on an independent UI-controller model (e.g. autoglm-phone-9b). It can plan and execute a sequence of UI actions (tap/type/swipe) from natural-language intent. When the user asks you to complete a UI task (e.g. open an app, search content, or finish a multi-step workflow across pages), you can call this package and let the sub-agent plan and execute the steps."},
+# "category": "Automatic",
+# "tools": [],
+# "states": [
+# {"id": "virtual_display", "condition": "ui.virtual_display", "inheritTools": True, "tools": [
+# {"name": "usage_advice", "description": {"zh": "UI子代理使用建议", "en": "UI sub-agent usage advice."}, "advice": True, "parameters": []},
+# {"name": "run_subagent_main", "description": {"zh": "在主屏幕运行 UI 子代理(强制主屏)。", "en": "Run the UI sub-agent on the main screen (forced main screen)."}, "parameters": [
+# {"name": "intent", "description": {"zh": "任务意图描述", "en": "Task intent description"}, "type": "string", "required": True},
+# {"name": "target_app", "description": {"zh": "目标应用名/包名(可选)", "en": "Target app name/package (optional)"}, "type": "string", "required": False},
+# {"name": "max_steps", "description": {"zh": "最大执行步数(默认20)", "en": "Maximum execution steps (default: 20)"}, "type": "number", "required": False}
+# ]},
+# {"name": "run_subagent_virtual", "description": {"zh": "在虚拟屏幕会话运行 UI 子代理(强制虚拟屏)。", "en": "Run the UI sub-agent on a virtual-display session (forced virtual screen)."}, "parameters": [
+# {"name": "intent", "description": {"zh": "任务意图描述", "en": "Task intent description"}, "type": "string", "required": True},
+# {"name": "target_app", "description": {"zh": "目标应用名/包名(可选)", "en": "Target app name/package (optional)"}, "type": "string", "required": False},
+# {"name": "max_steps", "description": {"zh": "最大执行步数(默认20)", "en": "Maximum execution steps (default: 20)"}, "type": "number", "required": False},
+# {"name": "agent_id", "description": {"zh": "虚拟屏会话 agent_id(必须为非 'default';可传入复用,或留空复用上次返回的 data.agentId)。", "en": "Virtual-screen session agent_id (must be non-'default'; pass to reuse, or omit to reuse returned data.agentId)."}, "type": "string", "required": False}
+# ]},
+# {"name": "run_subagent_parallel_virtual", "description": {"zh": "并行运行 1-4 个 UI 子代理(强制虚拟屏)。", "en": "Run 1-4 UI sub-agents in parallel (forced virtual screen)."}, "parameters": []},
+# {"name": "close_all_virtual_displays", "description": {"zh": "关闭所有虚拟屏幕。", "en": "Close all virtual displays."}, "parameters": []}
+# ]},
+# {"id": "main_screen", "condition": "!ui.virtual_display", "inheritTools": True, "tools": [
+# {"name": "usage_advice", "description": {"zh": "UI子代理使用建议(主屏模式)", "en": "UI sub-agent usage advice (main-screen mode)."}, "advice": True, "parameters": []},
+# {"name": "run_subagent_main", "description": {"zh": "在主屏幕运行 UI 子代理(强制主屏)。", "en": "Run the UI sub-agent on the main screen (forced main screen)."}, "parameters": [
+# {"name": "intent", "description": {"zh": "任务意图描述", "en": "Task intent description"}, "type": "string", "required": True},
+# {"name": "target_app", "description": {"zh": "目标应用名/包名(可选)", "en": "Target app name/package (optional)"}, "type": "string", "required": False},
+# {"name": "max_steps", "description": {"zh": "最大执行步数(默认20)", "en": "Maximum execution steps (default: 20)"}, "type": "number", "required": False}
+# ]}
+# ]}
+# ]
+# }
+
+import json
+import re
+import asyncio
+from urllib.parse import quote, unquote
+
+Error = Exception
+
+CACHE_KEY = '__operit_ui_subagent_cached_agent_id'
+_global_cache = {}
+
+
+def getCachedAgentId():
+ try:
+ return _global_cache.get(CACHE_KEY)
+ except Exception:
+ return None
+
+
+def setCachedAgentId(value):
+ try:
+ if value is None or len(str(value)) == 0:
+ _global_cache.pop(CACHE_KEY, None)
+ else:
+ _global_cache[CACHE_KEY] = str(value)
+ except Exception:
+ pass
+
+
+def getPackageState():
+ try:
+ return getState()
+ except Exception:
+ return None
+
+
+def errorMessage(e):
+ if isinstance(e, Exception):
+ return str(e)
+ return str(e)
+
+
+def getStringArrayFromUnknown(v):
+ if not isinstance(v, list):
+ return []
+ return [str(x or '').strip() for x in v if str(x or '').strip()]
+
+
+def parseInstalledAppEntry(raw):
+ s = str(raw or '').strip()
+ if not s:
+ return {"name": '', "raw": ''}
+ open_idx = s.rfind('(')
+ close_idx = s.rfind(')')
+ if open_idx >= 0 and close_idx == len(s) - 1 and open_idx < close_idx:
+ name = s[:open_idx].strip()
+ pkg = s[open_idx + 1:close_idx].strip()
+ if name and pkg:
+ return {"name": name, "pkg": pkg, "raw": s}
+ return {"name": s, "raw": s}
+
+
+async def getInstalledApps():
+ appList = await Tools.System.listApps(False)
+ rawItems = getStringArrayFromUnknown(getattr(appList, "packages", None) if appList else None)
+ entries = [e for e in [parseInstalledAppEntry(r) for r in rawItems] if e["name"]]
+ nameMap = {}
+ for e in entries:
+ nameMap[e["name"].lower()] = e["name"]
+ names = sorted(nameMap.values())
+ return {"entries": entries, "names": names}
+
+
+def matchTarget(targetApp, installed):
+ t = str(targetApp or '').strip()
+ if not t:
+ return None
+ tl = t.lower()
+ m = None
+ for a in installed:
+ if (a.get("pkg") or '').lower() == tl:
+ m = a
+ break
+ if not m:
+ for a in installed:
+ if a["name"].lower() == tl:
+ m = a
+ break
+ if not m:
+ for a in installed:
+ if a["raw"].lower() == tl:
+ m = a
+ break
+ if not m:
+ return None
+ run = m.get("pkg") or m["name"]
+ return {"run": run, "key": run.lower()}
+
+
+async def usage_advice(params):
+ state = getPackageState()
+ isMainScreen = str(state).lower() == 'main_screen'
+ return {
+ "success": True,
+ "message": 'UI子代理使用建议',
+ "data": {
+ "advice": (
+ "主屏模式:不支持 agent_id,会话复用策略不适用;不支持并行工具,一次只做一个明确子目标(必要时拆多次)。\n"
+ "屏幕选择规则:不传 agent_id 或传 'default' => 主屏幕;传入且不为 'default' => 虚拟屏(主屏模式下会忽略并强制主屏)。\n"
+ "启动前置(非常重要):当你第一次需要操作某个应用时,intent 开头必须写'启动XXX应用 ...',让子代理直接执行 Launch,而不是在桌面自己找。\n"
+ "对话无状态:每次调用是新对话,intent 写清 已完成/下一步/关键信息。\n"
+ "自包含:别用'这五个/继续/同上';多对象要么列清单+当前目标,要么先让子代理在当前页识别并复述清单。\n"
+ "失败与完成:半成功不算完成;未达成目标继续推进,连续 2-3 次失败再停并说明原因。"
+ ) if isMainScreen else (
+ "虚拟屏模式:尽量复用 agent_id(沿用 data.agentId)保持同一虚拟屏/同一应用上下文。\n"
+ "屏幕选择规则:不传 agent_id 或传 'default' => 主屏幕;传入且不为 'default' => 对应虚拟屏会话(虚拟屏必须可用,否则失败)。为避免误操作主屏,虚拟屏模式下首次调用建议显式传入 agent_id。\n"
+ "启动前置(非常重要):当你第一次使用某个 agent_id(新建或更换 agent_id)时,intent 开头必须写'启动XXX应用 ...',让子代理直接执行 Launch,而不是在桌面自己找。\n"
+ "对话无状态:每次调用是新对话,intent 写清 已完成/下一步/关键信息。\n"
+ "自包含:别用'这五个/继续/同上';多对象要么列清单+当前目标,要么先让子代理在当前页识别并复述清单。\n"
+ "对齐+并行:先清单后逐项(A→B→C);独立子任务/同一对象多入口优先并行(run_subagent_parallel_virtual),只重试失败分支。\n"
+ "并行资源约束:并行分支数必须受可用独立App/虚拟屏数量限制;同一个App/包名不能同时出现在两个虚拟屏/两个agent_id 中并行操作(会坏)。并行调用必须传 target_app_i=目标应用名,且各分支 target_app_i 不能重复;第2次/第N次并行不得擅自提高并行度,保持上限,只重试失败分支或改串行。\n"
+ "失败与完成:半成功不算完成;未达成目标继续推进,连续 2-3 次失败再停并说明原因。"
+ ),
+ },
+ }
+
+
+async def run_subagent_internal(params):
+ intent = params.get("intent")
+ max_steps = params.get("max_steps")
+ agent_id = params.get("agent_id")
+ target_app = params.get("target_app")
+
+ state = getPackageState()
+ isMainScreen = str(state).lower() == 'main_screen'
+
+ explicitAgentId = '' if agent_id is None else str(agent_id).strip()
+ cachedAgentId = getCachedAgentId()
+
+ if isMainScreen:
+ agentIdToUse = 'default'
+ else:
+ agentIdToUse = explicitAgentId if len(explicitAgentId) > 0 else cachedAgentId
+ if agentIdToUse is None or len(str(agentIdToUse).strip()) == 0:
+ return {
+ "success": False,
+ "message": "虚拟屏模式下未指定 agent_id:为避免误操作主屏幕,请显式传入 agent_id(且不为 'default')来使用虚拟屏会话;或先完成一次成功调用并复用返回的 data.agentId。",
+ }
+
+ targetAppForRun = target_app
+ if target_app and len(str(target_app).strip()) > 0:
+ installed = await getInstalledApps()
+ matched = matchTarget(target_app, installed["entries"])
+ if not matched:
+ return {
+ "success": False,
+ "message": f"目标应用不存在:当前给定的 target_app='{str(target_app).strip()}' 未在已安装应用中找到。已返回已安装应用名列表。",
+ "data": {
+ "target_app": str(target_app).strip(),
+ "installed_apps": installed["names"],
+ },
+ }
+ targetAppForRun = matched["run"]
+
+ result = await Tools.UI.runSubAgent(intent, max_steps, agentIdToUse, targetAppForRun)
+ agentId = getattr(result, "agentId", None) if result else None
+ if agentId and len(str(agentId).strip()) > 0 and str(agentId).strip().lower() != 'default':
+ setCachedAgentId(agentId)
+ return {
+ "success": True,
+ "message": 'UI子代理执行完成',
+ "data": result,
+ }
+
+
+async def run_subagent_main(params):
+ intent = params.get("intent")
+ max_steps = params.get("max_steps")
+ target_app = params.get("target_app")
+ return await run_subagent_internal({"intent": intent, "max_steps": max_steps, "target_app": target_app, "agent_id": 'default'})
+
+
+async def run_subagent_virtual(params):
+ intent = params.get("intent")
+ max_steps = params.get("max_steps")
+ agent_id = params.get("agent_id")
+ target_app = params.get("target_app")
+
+ explicitAgentId = '' if agent_id is None else str(agent_id).strip()
+ cachedAgentId = getCachedAgentId()
+ agentIdToUse = explicitAgentId if len(explicitAgentId) > 0 else cachedAgentId
+
+ if agentIdToUse is None or len(str(agentIdToUse).strip()) == 0 or str(agentIdToUse).strip().lower() == 'default':
+ return {
+ "success": False,
+ "message": "虚拟屏模式必须使用非 'default' 的 agent_id:请显式传入 agent_id(非 'default'),或先完成一次成功调用并复用返回的 data.agentId。",
+ }
+
+ return await run_subagent_internal({"intent": intent, "max_steps": max_steps, "target_app": target_app, "agent_id": str(agentIdToUse)})
+
+
+async def run_subagent_parallel_internal(params):
+ state = getPackageState()
+ isMainScreen = str(state).lower() == 'main_screen'
+ if isMainScreen:
+ return {
+ "success": False,
+ "message": "主屏模式不支持 run_subagent_parallel_virtual 并行调用。请改用 run_subagent_main 串行执行。",
+ }
+
+ slots = [1, 2, 3, 4]
+
+ activeSlots = []
+ for i in slots:
+ intent = params.get(f"intent_{i}")
+ if not intent or len(str(intent).strip()) == 0:
+ continue
+ targetApp = params.get(f"target_app_{i}")
+ activeSlots.append({"index": i, "targetApp": targetApp})
+
+ missingTargets = [s["index"] for s in activeSlots if s["targetApp"] is None or len(str(s["targetApp"]).strip()) == 0]
+ if len(missingTargets) > 0:
+ _missing_str = ', '.join(str(x) for x in missingTargets)
+ return {
+ "success": False,
+ "message": f"并行参数错误:intent_{_missing_str} 缺少 target_app_{_missing_str}(目标应用名)。并行时必须为每个启用分支传入目标应用名,用于检测'同一应用不能出现在两个虚拟屏/agent_id'的冲突。",
+ }
+
+ missingAgentIds = []
+ for s in activeSlots:
+ v = params.get(f"agent_id_{s['index']}")
+ id_ = '' if v is None else str(v).strip()
+ if len(id_) == 0 or id_.lower() == 'default':
+ missingAgentIds.append(s["index"])
+ if len(missingAgentIds) > 0:
+ return {
+ "success": False,
+ "message": f"并行参数错误:虚拟屏并行模式下,每个启用分支必须显式传入非 'default' 的 agent_id。缺少/非法 agent_id 的分支:{', '.join(f'#{i}' for i in missingAgentIds)}。",
+ }
+
+ installed = await getInstalledApps()
+ resolvedBySlot = {}
+ missingApps = []
+
+ for s in activeSlots:
+ t = str(s["targetApp"] or '').strip()
+ m = matchTarget(t, installed["entries"])
+ if not m:
+ missingApps.append(t)
+ else:
+ resolvedBySlot[s["index"]] = m
+
+ if len(missingApps) > 0:
+ _apps_str = ', '.join(f"'{str(s).strip()}'" for s in missingApps)
+ return {
+ "success": False,
+ "message": f"目标应用不存在:当前给定的 target_app 列表中包含未安装/不存在的应用:{_apps_str}。已返回已安装应用名列表。",
+ "data": {
+ "missing_apps": missingApps,
+ "installed_apps": installed["names"],
+ },
+ }
+
+ used = {}
+ for s in activeSlots:
+ key = (resolvedBySlot.get(s["index"]) or {}).get("key") or str(s["targetApp"]).strip().lower()
+ prev = used.get(key)
+ if prev is not None:
+ return {
+ "success": False,
+ "message": f"并行参数错误:target_app_{prev} 与 target_app_{s['index']} 重复(同一目标应用='{str(s['targetApp']).strip()}')。同一应用不能同时在两个虚拟屏/agent_id 中并行操作。",
+ }
+ used[key] = s["index"]
+
+ async def runOne(i):
+ intent = params.get(f"intent_{i}")
+ if not intent or len(str(intent).strip()) == 0:
+ return None
+ maxSteps = params.get(f"max_steps_{i}")
+ agentId = params.get(f"agent_id_{i}")
+ targetApp = (resolvedBySlot.get(i) or {}).get("run") or params.get(f"target_app_{i}")
+ try:
+ result = await Tools.UI.runSubAgent(
+ str(intent),
+ maxSteps,
+ None if agentId is None or len(str(agentId).strip()) == 0 else str(agentId).strip(),
+ targetApp,
+ )
+ return {"index": i, "success": True, "result": result}
+ except Exception as e:
+ return {"index": i, "success": False, "error": errorMessage(e)}
+
+ tasks = [runOne(i) for i in slots]
+ raw_results = await asyncio.gather(*tasks)
+ results = [r for r in raw_results if r is not None]
+ okCount = len([r for r in results if r["success"]])
+ return {
+ "success": True,
+ "message": f"并行UI子代理执行完成:成功 {okCount} 个 / 共 {len(results)} 个",
+ "data": {"results": results},
+ }
+
+
+async def run_subagent_parallel_virtual(params):
+ return await run_subagent_parallel_internal(params)
+
+
+async def close_all_virtual_displays(params):
+ result = await toolCall('close_all_virtual_displays', {})
+ ok = getattr(result, "success", None) if result else None
+ ok = ok is not False
+ error = getattr(result, "error", None) if result else None
+ return {
+ "success": ok,
+ "message": '已关闭所有虚拟屏幕。' if ok else f"关闭虚拟屏幕失败:{str(error) if error else 'unknown error'}",
+ "data": result,
+ }
+
+
+async def wrapToolExecution(func, params):
+ try:
+ result = await func(params)
+ complete(result)
+ except Exception as error:
+ console.error(f"Tool {func.__name__} failed unexpectedly", error)
+ complete({
+ "success": False,
+ "message": f"工具执行时发生意外错误: {errorMessage(error)}",
+ })
+
+
+async def _usage_advice_wrapper(params):
+ await wrapToolExecution(usage_advice, params)
+
+
+async def _run_subagent_main_wrapper(params):
+ await wrapToolExecution(run_subagent_main, params)
+
+
+async def _run_subagent_virtual_wrapper(params):
+ await wrapToolExecution(run_subagent_virtual, params)
+
+
+async def _close_all_virtual_displays_wrapper(params):
+ await wrapToolExecution(close_all_virtual_displays, params)
+
+
+async def _run_subagent_parallel_virtual_wrapper(params):
+ await wrapToolExecution(run_subagent_parallel_virtual, params)
+
+
+exports.usage_advice = _usage_advice_wrapper
+exports.run_subagent_main = _run_subagent_main_wrapper
+exports.run_subagent_virtual = _run_subagent_virtual_wrapper
+exports.close_all_virtual_displays = _close_all_virtual_displays_wrapper
+exports.run_subagent_parallel_virtual = _run_subagent_parallel_virtual_wrapper
diff --git a/operit_py/automatic_xiaohongshu_assistant.py b/operit_py/automatic_xiaohongshu_assistant.py
new file mode 100644
index 0000000..2f25639
--- /dev/null
+++ b/operit_py/automatic_xiaohongshu_assistant.py
@@ -0,0 +1,822 @@
+# METADATA
+# {
+# "name": "Automatic_xiaohongshu_assistant",
+# "display_name": {"zh": "小红书自动化助手", "en": "Xiaohongshu Automation Assistant"},
+# "description": {"zh": "高级小红书智能助手,通过UI自动化技术实现小红书应用交互,支持内容浏览、搜索查看、评论互动、内容发布等功能,为AI赋予小红书社交和内容创作能力。适用于内容营销、社交互动、生活分享等场景。", "en": "Advanced Xiaohongshu (RED) assistant powered by UI automation. Supports browsing feeds, searching and viewing content, commenting and interactions, and publishing posts, enabling AI-driven social interaction and content creation. Useful for content marketing and social engagement."},
+# "category": "Automatic",
+# "tools": [
+# {"name": "workflow_guide", "description": {"zh": "小红书助手工具使用流程指南。", "en": "Workflow guide for the Xiaohongshu assistant."}, "parameters": []},
+# {"name": "browse_home_feed", "description": {"zh": "浏览小红书主页信息流,获取推荐内容", "en": "Browse the Xiaohongshu home feed and collect recommended content."}, "parameters": [
+# {"name": "scroll_count", "description": {"zh": "滚动次数,控制浏览的内容量,默认为3次", "en": "Number of scrolls (default: 3)."}, "type": "number", "required": False},
+# {"name": "collect_posts", "description": {"zh": "是否收集帖子信息,默认为true", "en": "Whether to collect post info (default: true)."}, "type": "boolean", "required": False}
+# ]},
+# {"name": "search_content", "description": {"zh": "在小红书中搜索内容、用户或话题", "en": "Search content, users, or topics in Xiaohongshu."}, "parameters": [
+# {"name": "keyword", "description": {"zh": "搜索关键词", "en": "Search keyword."}, "type": "string", "required": True},
+# {"name": "search_type", "description": {"zh": "搜索类型:comprehensive(综合)、note(笔记)、user(用户)、topic(话题)", "en": "Search type: comprehensive, note, user, topic."}, "type": "string", "required": False}
+# ]},
+# {"name": "view_post", "description": {"zh": "查看指定的帖子详情", "en": "View details of a specific post."}, "parameters": [
+# {"name": "post_title", "description": {"zh": "要查看的帖子标题关键词", "en": "Keyword to match the post title (optional)."}, "type": "string", "required": False},
+# {"name": "post_index", "description": {"zh": "要查看的帖子在列表中的索引位置(从1开始)", "en": "Index of the post in the list (1-based, optional)."}, "type": "number", "required": False}
+# ]},
+# {"name": "like_post", "description": {"zh": "给当前帖子点赞", "en": "Like the current post."}, "parameters": []},
+# {"name": "collect_post", "description": {"zh": "收藏当前帖子", "en": "Save (collect) the current post."}, "parameters": [
+# {"name": "collection_name", "description": {"zh": "收藏夹名称,留空则使用默认收藏夹", "en": "Collection name (leave empty to use the default collection)."}, "type": "string", "required": False}
+# ]},
+# {"name": "follow_user", "description": {"zh": "关注当前帖子的作者", "en": "Follow the author of the current post."}, "parameters": []},
+# {"name": "unfollow_user", "description": {"zh": "取消关注当前帖子的作者", "en": "Unfollow the author of the current post."}, "parameters": []},
+# {"name": "comment_post", "description": {"zh": "在当前帖子下发表评论", "en": "Post a comment under the current post."}, "parameters": [
+# {"name": "comment_text", "description": {"zh": "要发表的评论内容", "en": "Comment text to post."}, "type": "string", "required": True}
+# ]},
+# {"name": "get_post_info", "description": {"zh": "获取当前帖子的详细信息", "en": "Get detailed info of the current post."}, "parameters": []},
+# {"name": "publish_post", "description": {"zh": "发布新的帖子内容", "en": "Publish a new post."}, "parameters": [
+# {"name": "content_text", "description": {"zh": "帖子文字内容", "en": "Post text content."}, "type": "string", "required": True},
+# {"name": "image_paths", "description": {"zh": "图片文件路径列表,用逗号分隔", "en": "Comma-separated list of image file paths (optional)."}, "type": "string", "required": False},
+# {"name": "tags", "description": {"zh": "话题标签,用逗号分隔", "en": "Comma-separated topic tags (optional)."}, "type": "string", "required": False},
+# {"name": "location", "description": {"zh": "地理位置信息", "en": "Location information (optional)."}, "type": "string", "required": False}
+# ]},
+# {"name": "navigate_to_home", "description": {"zh": "导航到小红书首页", "en": "Navigate to the Xiaohongshu home page."}, "parameters": []},
+# {"name": "navigate_to_profile", "description": {"zh": "导航到个人主页", "en": "Navigate to the profile page."}, "parameters": []},
+# {"name": "navigate_to_search", "description": {"zh": "导航到搜索页面", "en": "Navigate to the search page."}, "parameters": []},
+# {"name": "navigate_to_publish", "description": {"zh": "导航到发布页面", "en": "Navigate to the publish page."}, "parameters": []},
+# {"name": "back_to_feed", "description": {"zh": "从帖子详情页返回到信息流列表", "en": "Return from the post detail page back to the feed list."}, "parameters": []}
+# ]
+# }
+# 注意:本脚本深度依赖 Android UI 自动化 API(UINode、Tools.UI),在非 Android 平台上运行时相关功能不可用。
+
+import json
+import re
+from urllib.parse import quote, unquote
+
+Error = Exception
+
+XIAOHONGSHU_PACKAGE = "com.xingin.xhs"
+MAIN_ACTIVITY = "com.xingin.xhs.activity.SplashActivity"
+HOME_ACTIVITY = "com.xingin.xhs.index.v2.IndexActivityV2"
+
+lastBrowseResults = []
+
+
+def createResponse(success, message, data=None):
+ if data is None:
+ data = {}
+ if isinstance(data, str):
+ return {"success": True, "message": message, "data": data}
+ result = {"success": success, "message": message}
+ if isinstance(data, dict):
+ result.update(data)
+ return result
+
+
+async def findAndClick(finder):
+ element = await finder()
+ if element:
+ await element.click()
+ await Tools.System.sleep(1000)
+ return True
+ return False
+
+
+async def findAndClickByText(text):
+ async def _finder():
+ return (await UINode.getCurrentPage()).findByText(text)
+ return await findAndClick(_finder)
+
+
+async def ensureMain(packageName=XIAOHONGSHU_PACKAGE):
+ pageInfo = await Tools.UI.getPageInfo()
+
+ if pageInfo.packageName != packageName:
+ await Tools.System.startApp(packageName)
+ await Tools.System.sleep(3000)
+ pageInfo = await Tools.UI.getPageInfo()
+
+ async def isAlreadyOnHome():
+ page = await UINode.getCurrentPage()
+ followButton = page.findByContentDesc("关注")
+ discoverButton = page.findByContentDesc("发现")
+ searchButton = page.findByContentDesc("搜索")
+ return followButton and discoverButton and searchButton
+
+ if await isAlreadyOnHome():
+ console.log("已经在首页。")
+ return True
+
+ console.log("尝试返回到首页。")
+ for i in range(4):
+ await Tools.UI.pressKey("KEYCODE_BACK")
+ await Tools.System.sleep(1000)
+ if await isAlreadyOnHome():
+ console.log("成功回到首页。")
+ return True
+ pageInfo = await Tools.UI.getPageInfo()
+ if pageInfo.packageName != packageName:
+ console.log("返回时退出了app。")
+ break
+
+ console.log("返回失败,重启app。")
+ await Tools.System.stopApp(packageName)
+ await Tools.System.sleep(1000)
+ await Tools.System.startApp(packageName)
+ await Tools.System.sleep(5000)
+
+ pageInfo = await Tools.UI.getPageInfo()
+ if pageInfo.packageName == packageName:
+ console.log("App重启成功。")
+ return True
+
+ console.error(f"无法导航到 {packageName} 的首页。")
+ return False
+
+
+async def browse_home_feed(params):
+ scroll_count = params.get("scroll_count", 5)
+ collect_posts = params.get("collect_posts", False)
+ posts = []
+ console.log(f"开始浏览, 滑动次数: {scroll_count}, 是否收集帖子: {collect_posts}")
+
+ for i in range(scroll_count):
+ console.log(f"第 {i + 1} 次滑动...")
+
+ if collect_posts:
+ currentPage = await UINode.getCurrentPage()
+ searchScopeNode = currentPage
+ console.log("将在整个页面范围内查找帖子。")
+
+ allFrameLayouts = searchScopeNode.findAll(lambda n: n.className == 'FrameLayout')
+ console.log(f"[调试] 在查找范围内找到了 {len(allFrameLayouts)} 个 FrameLayout 节点。")
+ for frame in allFrameLayouts:
+ if getattr(frame, "contentDesc", None):
+ console.log(f'[调试] FrameLayout contentDesc: "{frame.contentDesc}"')
+
+ postNodes = [n for n in allFrameLayouts if getattr(n, "contentDesc", None) and ' 来自' in n.contentDesc]
+
+ console.log(f"本次查找共发现 {len(postNodes)} 个帖子。")
+
+ for postNode in postNodes:
+ desc = getattr(postNode, "contentDesc", None)
+ if desc:
+ try:
+ parts = desc.split(' 来自')
+ if len(parts) > 1:
+ authorAndLikesStr = parts.pop().strip()
+ title = ' 来自'.join(parts).strip()
+ title = re.sub(r'^(?:笔\s*记|视\s*频)\s*', '', title)
+
+ words = authorAndLikesStr.split()
+ if len(words) > 0:
+ likesWithSuffix = words.pop()
+ author = ' '.join(words)
+
+ if title and likesWithSuffix and likesWithSuffix.endswith('赞'):
+ post = {
+ "title": title,
+ "author": author or "未知作者",
+ "likes": likesWithSuffix.replace('赞', '').strip(),
+ }
+ if not any(p["title"] == post["title"] and p["author"] == post["author"] for p in posts):
+ console.log(f" -> 收集到新帖子: {post['title']} | 作者: {post['author']} | 赞: {post['likes']}")
+ posts.append(post)
+ except Exception as e:
+ console.log(f'解析时出现异常: "{desc}"', e)
+
+ await Tools.UI.swipe(500, 1800, 500, 500)
+ await Tools.System.sleep(2500)
+
+ console.log(f"浏览结束, 共收集到 {len(posts)} 个独立帖子。")
+ return createResponse(True, "成功浏览主页信息流", {"posts": posts})
+
+
+async def applySearchFilter(filterType):
+ console.log(f"应用搜索过滤器: {filterType}")
+ page = await UINode.getCurrentPage()
+ filterButton = None
+ if filterType == "note":
+ filterButton = page.findByText("笔记")
+ elif filterType == "user":
+ filterButton = page.findByText("用户")
+ elif filterType == "topic":
+ filterButton = page.findByText("话题")
+
+ if filterButton:
+ await filterButton.click()
+ await Tools.System.sleep(2000)
+
+
+def extractPostInfoFromNode(node, index):
+ relativeLayout = node.findByClass("RelativeLayout")
+ if relativeLayout:
+ directImageViews = [child for child in (getattr(relativeLayout, "children", None) or []) if child.className == "ImageView"]
+ if len(directImageViews) > 1:
+ return None
+
+ allTextViews = node.findAllByClass("TextView")
+
+ if any(tv.text == "广告" for tv in allTextViews if getattr(tv, "text", None)):
+ return None
+
+ if len(allTextViews) < 2:
+ return None
+
+ sortedByLength = sorted(allTextViews, key=lambda tv: len(tv.text or ""), reverse=True)
+ titleNode = sortedByLength[0]
+ if not titleNode or not getattr(titleNode, "text", None) or len(titleNode.text) < 5:
+ return None
+ title = titleNode.text
+
+ otherTextViews = [tv for tv in allTextViews if tv != titleNode]
+
+ likesRegex = re.compile(r'^\s*\d+(\.\d+)?\s*万?\s*$')
+ likesNode = next((tv for tv in otherTextViews if getattr(tv, "text", None) and likesRegex.match(tv.text.strip())), None)
+ likes = likesNode.text.strip() if likesNode and getattr(likesNode, "text", None) else "未知"
+
+ dateRegex = re.compile(r'^\d{2,4}-\d{2}(-\d{2})?$')
+ authorNode = next((tv for tv in otherTextViews if getattr(tv, "text", None) and tv != likesNode and not dateRegex.match(tv.text.strip())), None)
+ author = authorNode.text.strip() if authorNode and getattr(authorNode, "text", None) else "未知作者"
+
+ if title == author:
+ return None
+
+ return {"index": index, "title": title, "author": author, "likes": likes, "element": node}
+
+
+async def getSearchResults(desiredCount=15):
+ results = []
+ seenTitles = set()
+ maxScrolls = 5
+ lastResultCount = -1
+ scrollCount = 0
+
+ for i in range(maxScrolls):
+ if len(results) >= desiredCount:
+ break
+ page = await UINode.getCurrentPage()
+ resultsContainer = page.findByClass("RecyclerView")
+
+ if not resultsContainer:
+ console.log("未找到搜索结果容器 (RecyclerView)")
+ if i == 0:
+ return []
+ break
+
+ for item in (getattr(resultsContainer, "children", None) or []):
+ postInfo = extractPostInfoFromNode(item, len(results) + 1)
+ if postInfo and postInfo["title"] not in seenTitles:
+ seenTitles.add(postInfo["title"])
+ results.append(postInfo)
+ if len(results) >= desiredCount:
+ break
+
+ if len(results) >= desiredCount:
+ break
+
+ if len(results) == lastResultCount:
+ console.log("滚动未产生新结果,停止滚动")
+ break
+ lastResultCount = len(results)
+
+ await Tools.UI.swipe(540, 1500, 540, 800)
+ await Tools.System.sleep(2000)
+ scrollCount += 1
+
+ console.log(f"Scrolling back up {scrollCount} times.")
+ for i in range(scrollCount):
+ await Tools.UI.swipe(540, 800, 540, 1550)
+ await Tools.System.sleep(1000)
+ return results
+
+
+async def search_content(params):
+ keyword = params.get("keyword")
+ search_type = params.get("search_type", "comprehensive")
+ console.log(f"搜索内容: {keyword}, 类型: {search_type}")
+
+ if not await ensureMain():
+ return createResponse(False, "无法启动或切换到小红书")
+
+ page = await UINode.getCurrentPage()
+ searchIcon = page.findByContentDesc("搜索")
+
+ if not searchIcon:
+ return createResponse(False, "在主页上找不到带 '搜索' contentDesc 的图标")
+ await searchIcon.click()
+ await Tools.System.sleep(2000)
+
+ page = await UINode.getCurrentPage()
+ searchInput = page.findByClass("EditText")
+
+ if not searchInput:
+ return createResponse(False, "在搜索页面找不到输入框")
+
+ await searchInput.click()
+ await Tools.System.sleep(1000)
+ await Tools.UI.setText(keyword)
+ await Tools.System.sleep(500)
+
+ page = await UINode.getCurrentPage()
+ searchButtons = page.findAllByText("搜索")
+ searchButton = searchButtons[-1] if searchButtons else None
+
+ if not searchButton:
+ console.log("找不到文本为'搜索'的按钮,尝试按回车键")
+ await Tools.UI.pressKey("KEYCODE_ENTER")
+ else:
+ await searchButton.click()
+ await Tools.System.sleep(3000)
+
+ if search_type != "comprehensive":
+ await applySearchFilter(search_type)
+
+ results = await getSearchResults()
+
+ return createResponse(True, f"搜索到 {len(results)} 条相关内容", {
+ "keyword": keyword,
+ "search_type": search_type,
+ "results": [{k: v for k, v in r.items() if k != "element"} for r in results],
+ "result_count": len(results),
+ })
+
+
+async def view_post(params):
+ post_title = params.get("post_title")
+ post_index = params.get("post_index")
+
+ if not post_title and not post_index:
+ return createResponse(False, "请提供帖子标题或索引")
+
+ console.log(f'尝试查看帖子 - 标题: "{post_title}", 索引: {post_index}')
+
+ targetPost = None
+ currentIndex = 0
+ seenTitles = set()
+ maxScrolls = 5
+
+ for i in range(maxScrolls):
+ page = await UINode.getCurrentPage()
+ resultsContainer = page.findByClass("RecyclerView")
+
+ if not resultsContainer:
+ if i == 0:
+ return createResponse(False, "未找到帖子列表容器 (RecyclerView)")
+ break
+
+ for item in (getattr(resultsContainer, "children", None) or []):
+ postInfo = extractPostInfoFromNode(item, 0)
+ if postInfo and postInfo["title"] not in seenTitles:
+ seenTitles.add(postInfo["title"])
+ currentIndex += 1
+
+ shouldClick = False
+ if post_index and currentIndex == post_index:
+ shouldClick = True
+ elif post_title and post_title in postInfo["title"]:
+ shouldClick = True
+
+ if shouldClick:
+ targetPost = {"title": postInfo["title"], "item": postInfo["element"]}
+ break
+
+ if targetPost:
+ break
+
+ await Tools.UI.swipe(540, 1500, 540, 800)
+ await Tools.System.sleep(2000)
+
+ if targetPost:
+ await targetPost["item"].click()
+ await Tools.System.sleep(3000)
+ res = await get_post_info({})
+ return createResponse(True, f'成功打开帖子并完成内容加载: "{targetPost["title"]}"', res)
+ else:
+ return createResponse(False, "未找到指定的帖子", {"post_title": post_title, "post_index": post_index})
+
+
+async def isInPostDetail():
+ page = await UINode.getCurrentPage()
+ likeButton = page.findByContentDesc("点赞") or page.findByText("赞") or page.find(lambda n: getattr(n, "contentDesc", None) and "点赞" in n.contentDesc)
+ commentButton = page.findByContentDesc("评论") or page.findByText("评论") or page.find(lambda n: getattr(n, "contentDesc", None) and "评论" in n.contentDesc)
+ return bool(likeButton or commentButton)
+
+
+async def findLikeButton():
+ page = await UINode.getCurrentPage()
+ button = page.findByContentDesc("点赞") or page.findByText("赞") or page.find(lambda n: getattr(n, "contentDesc", None) and "点赞" in n.contentDesc)
+ return button or None
+
+
+async def findCollectButton():
+ page = await UINode.getCurrentPage()
+ button = page.findByContentDesc("收藏") or page.findByText("收藏") or page.find(lambda n: getattr(n, "contentDesc", None) and "收藏" in n.contentDesc)
+ return button or None
+
+
+async def findCommentButton():
+ page = await UINode.getCurrentPage()
+ button = page.findByContentDesc("评论") or page.findByText("评论") or page.find(lambda n: getattr(n, "contentDesc", None) and "评论" in n.contentDesc)
+ return button or None
+
+
+async def like_post(params):
+ if not await isInPostDetail():
+ return createResponse(False, "当前不在帖子详情页")
+ console.log("给帖子点赞")
+ likeButton = await findLikeButton()
+ if likeButton:
+ await likeButton.click()
+ await Tools.System.sleep(1000)
+ return createResponse(True, "点赞成功")
+ else:
+ return createResponse(False, "未找到点赞按钮")
+
+
+async def collect_post(params):
+ if not await isInPostDetail():
+ return createResponse(False, "当前不在帖子详情页")
+ collection_name = params.get("collection_name")
+ console.log(f"收藏帖子到: {collection_name or '默认收藏夹'}")
+ collectButton = await findCollectButton()
+ if collectButton:
+ await collectButton.click()
+ await Tools.System.sleep(2000)
+ if collection_name:
+ collectionPage = await UINode.getCurrentPage()
+ targetCollection = collectionPage.findByText(collection_name)
+ if targetCollection:
+ await targetCollection.click()
+ await Tools.System.sleep(1000)
+ return createResponse(True, "收藏成功", {"collection_name": collection_name})
+ else:
+ return createResponse(False, "未找到收藏按钮", {"collection_name": collection_name})
+
+
+async def follow_user(params):
+ if not await isInPostDetail():
+ return createResponse(False, "当前不在帖子详情页")
+ console.log("关注用户")
+ page = await UINode.getCurrentPage()
+ followButton = page.findByText("关注")
+ if followButton:
+ await followButton.click()
+ await Tools.System.sleep(1000)
+ return createResponse(True, "关注成功")
+ else:
+ return createResponse(False, "未找到关注按钮或已经关注")
+
+
+async def unfollow_user(params):
+ if not await isInPostDetail():
+ return createResponse(False, "当前不在帖子详情页")
+ console.log("不关注用户")
+ page = await UINode.getCurrentPage()
+ followButton = page.findByText("已关注")
+ if followButton:
+ await followButton.click()
+ await Tools.System.sleep(1000)
+ node = (await UINode.getCurrentPage()).findByText("不再关注")
+ if node:
+ await node.click()
+ return createResponse(True, "取消关注成功")
+ else:
+ return createResponse(False, "未找到不关注按钮或已经不关注")
+
+
+async def comment_post(params):
+ if not await isInPostDetail():
+ return createResponse(False, "当前不在帖子详情页")
+ comment_text = params.get("comment_text")
+ console.log(f"发表评论: {comment_text}")
+ page = await UINode.getCurrentPage()
+ commentBox = page.findByContentDesc("评论框")
+ if commentBox:
+ await commentBox.click()
+ await Tools.System.sleep(1000)
+ await Tools.UI.setText(comment_text)
+ await Tools.System.sleep(500)
+ sendButton = (await UINode.getCurrentPage()).findByText("发送")
+ if sendButton:
+ await sendButton.click()
+ await Tools.System.sleep(2000)
+ return createResponse(True, "评论发表成功", {"comment_text": comment_text})
+ else:
+ return createResponse(False, "未找到发送按钮", {"comment_text": comment_text})
+ else:
+ return createResponse(False, "未找到评论输入框", {"comment_text": comment_text})
+
+
+async def get_post_info(params):
+ if not await isInPostDetail():
+ return createResponse(False, "当前不在帖子详情页")
+ console.log("获取帖子信息...")
+
+ await Tools.UI.swipe(540, 1600, 540, 1000)
+ await Tools.System.sleep(1500)
+
+ page = await UINode.getCurrentPage()
+ postInfo = {
+ "title": "未知", "author": "未知", "content": "",
+ "like_count": "未知", "comment_count": "未知", "collect_count": "未知",
+ "publish_date": "未知", "comments": [],
+ }
+
+ authorButton = page.find(lambda n: getattr(n, "contentDesc", None) and n.contentDesc.startswith("作者,"))
+ authorTextView = None
+ if authorButton:
+ authorTextView = authorButton.findByClass("TextView")
+ if authorTextView and getattr(authorTextView, "text", None):
+ postInfo["author"] = authorTextView.text
+ elif getattr(authorButton, "contentDesc", None):
+ parts = authorButton.contentDesc.split(',')
+ if len(parts) > 1:
+ postInfo["author"] = parts[1].strip()
+
+ allNodesWithDesc = page.findAll(lambda n: bool(getattr(n, "contentDesc", None)))
+ dateNode = next((n for n in allNodesWithDesc if getattr(n, "contentDesc", None) and re.match(r'^\d{2}-\d{2}$', n.contentDesc.strip())), None)
+ if dateNode and getattr(dateNode, "contentDesc", None):
+ postInfo["publish_date"] = dateNode.contentDesc.strip()
+
+ def findCountFromButton(button):
+ if not button:
+ return "未知"
+ if getattr(button, "contentDesc", None):
+ match = re.search(r'(\d+(\.\d+)?[万wW]?)', button.contentDesc)
+ if match and match.group(1):
+ return match.group(1)
+ countNode = button.findByClass("TextView")
+ if countNode and getattr(countNode, "text", None) and re.match(r'^\d+(\.\d+)?[万wW]?$', countNode.text.strip()):
+ return countNode.text.strip()
+ return "未知"
+
+ postInfo["like_count"] = findCountFromButton(await findLikeButton())
+ postInfo["comment_count"] = findCountFromButton(await findCommentButton())
+ postInfo["collect_count"] = findCountFromButton(await findCollectButton())
+
+ allRecyclerViews = page.findAllByClass("RecyclerView")
+ commentListInitial = next((rv for rv in allRecyclerViews if not (getattr(rv, "parent", None) and rv.parent.className == 'FrameLayout' and getattr(rv.parent, "contentDesc", None) and ("图片" in rv.parent.contentDesc or "视频" in rv.parent.contentDesc))), None)
+
+ commentTextViews = set(commentListInitial.findAllByClass("TextView") if commentListInitial else [])
+ allTextViews = page.findAllByClass("TextView")
+ mainTextViews = [tv for tv in allTextViews if tv and tv not in commentTextViews]
+
+ ignoredTexts = {'关注', '赞', '评论', '收藏', '翻译', '说点什么...', postInfo["author"]}
+ if postInfo["author"] == '未知':
+ ignoredTexts.discard('未知')
+
+ contentTextNodes = [tv for tv in mainTextViews if getattr(tv, "text", None) and tv.text.strip() and tv.text.strip() not in ignoredTexts and not re.match(r'^\d+(\.\d+)?[万wW]?$', tv.text) and tv != authorTextView]
+
+ if len(contentTextNodes) > 0:
+ postInfo["title"] = contentTextNodes[0].text.strip()
+ if len(contentTextNodes) > 1:
+ postInfo["content"] = '\n'.join([tv.text.strip() for tv in contentTextNodes[1:]])
+
+ console.log("开始滚动并解析评论...")
+ seenComments = set()
+ stableScrolls = 0
+ maxScrolls = 10
+
+ for i in range(maxScrolls):
+ currentCommentCount = len(postInfo["comments"])
+ currentPage = await UINode.getCurrentPage()
+
+ commentList = next((rv for rv in currentPage.findAllByClass("RecyclerView") if not (getattr(rv, "parent", None) and rv.parent.className == 'FrameLayout' and getattr(rv.parent, "contentDesc", None) and ("图片" in rv.parent.contentDesc or "视频" in rv.parent.contentDesc))), None)
+
+ if commentList:
+ for item in (getattr(commentList, "children", None) or []):
+ if len(postInfo["comments"]) >= 20:
+ break
+ textViews = item.findAllByClass("TextView")
+ if len(textViews) < 2:
+ continue
+
+ comment = {"author": "未知", "content": "未知", "likes": "0", "date": "未知"}
+ likesRegex = re.compile(r'^\d+(\.\d+)?[万wW]?$')
+ likesNode = item.find(lambda n: n.className == 'TextView' and getattr(n, "text", None) and likesRegex.match(n.text))
+ comment["likes"] = likesNode.text if likesNode and getattr(likesNode, "text", None) else "0"
+
+ contentCandidates = [tv for tv in textViews if getattr(tv, "text", None) and tv != likesNode]
+ if len(contentCandidates) == 0:
+ continue
+ contentCandidates.sort(key=lambda tv: len(tv.text or ""), reverse=True)
+
+ contentText = contentCandidates[0].text or ""
+ authorNode = contentCandidates[1] if len(contentCandidates) > 1 else None
+
+ comment["author"] = authorNode.text.replace("作者", "").strip() if authorNode and getattr(authorNode, "text", None) else "未知"
+
+ dateRegex = re.compile(r'(\d{2}-\d{2}( \d{2}:\d{2})?|\d+天前|昨天 \d{2}:\d{2})')
+ dateMatch = dateRegex.search(contentText)
+ if dateMatch:
+ comment["date"] = dateMatch.group(0)
+ comment["content"] = dateRegex.sub(' ', contentText).replace('回复', '').strip()
+ else:
+ comment["content"] = contentText.replace('回复', '').strip()
+
+ if comment["content"].startswith(comment["author"]):
+ comment["content"] = comment["content"][len(comment["author"]):].strip()
+
+ if comment["content"] == comment["author"] or not comment["content"]:
+ continue
+
+ uniqueKey = f'{comment["author"]}:{comment["content"]}'
+ if comment["content"] and comment["content"] != "未知" and uniqueKey not in seenComments:
+ seenComments.add(uniqueKey)
+ postInfo["comments"].append(comment)
+
+ if len(postInfo["comments"]) >= 20:
+ console.log("已收集到20条评论,停止滚动。")
+ break
+
+ if len(postInfo["comments"]) == currentCommentCount:
+ stableScrolls += 1
+ if stableScrolls >= 2:
+ console.log("滚动两次未发现新评论,停止。")
+ break
+ else:
+ stableScrolls = 0
+
+ console.log(f"第 {i + 1}/{maxScrolls} 次滚动... 当前评论数: {len(postInfo['comments'])}")
+ await Tools.UI.swipe(540, 1600, 540, 800)
+ await Tools.System.sleep(2000)
+
+ return createResponse(True, "获取帖子信息成功", {"post_info": postInfo})
+
+
+async def publish_post(params):
+ content_text = params.get("content_text")
+ image_paths = params.get("image_paths")
+ tags = params.get("tags")
+ location = params.get("location")
+
+ console.log(f"发布帖子: {content_text[:20]}...")
+
+ if not await ensureMain():
+ return createResponse(False, "无法启动或切换到小红书")
+
+ page = await UINode.getCurrentPage()
+ publishButton = page.findByText("发布") or page.findByText("+") or page.findByContentDesc("发布")
+
+ if not publishButton:
+ return createResponse(False, "未找到发布按钮")
+
+ await publishButton.click()
+ await Tools.System.sleep(3000)
+
+ noteOption = (await UINode.getCurrentPage()).findByText("笔记") or (await UINode.getCurrentPage()).findByText("图文")
+ if noteOption:
+ await noteOption.click()
+ await Tools.System.sleep(2000)
+
+ if image_paths:
+ imagePaths = image_paths.split(',')
+ for imagePath in imagePaths:
+ console.log(f"添加图片: {imagePath.strip()}")
+
+ pageForInput = await UINode.getCurrentPage()
+ textInput = pageForInput.findByClass("EditText") or pageForInput.findByText("分享你的生活...")
+
+ if textInput:
+ await textInput.click()
+ await Tools.System.sleep(1000)
+ await Tools.UI.setText(content_text)
+ await Tools.System.sleep(1000)
+
+ if tags:
+ tagList = tags.split(',')
+ for tag in tagList:
+ tagText = tag.strip()
+ if tagText:
+ await Tools.UI.setText(f" #{tagText}")
+ await Tools.System.sleep(500)
+
+ if location:
+ locationButton = (await UINode.getCurrentPage()).findByText("添加地点")
+ if locationButton:
+ await locationButton.click()
+ await Tools.System.sleep(2000)
+ await Tools.UI.setText(location)
+ await Tools.System.sleep(1000)
+ firstLocation = (await UINode.getCurrentPage()).findByClass("TextView")
+ if firstLocation and getattr(firstLocation, "text", None) and location in firstLocation.text:
+ await firstLocation.click()
+ await Tools.System.sleep(1000)
+
+ finalPublishButton = (await UINode.getCurrentPage()).findByText("发布")
+ if finalPublishButton:
+ await finalPublishButton.click()
+ await Tools.System.sleep(5000)
+ return createResponse(True, "帖子发布成功", {"content_text": content_text, "image_paths": image_paths, "tags": tags, "location": location})
+ else:
+ return createResponse(False, "未找到最终发布按钮", {"content_text": content_text})
+
+
+async def navigate_to_home(params):
+ console.log("导航到首页")
+ if await ensureMain():
+ page = await UINode.getCurrentPage()
+ homeTab = page.findByText("首页")
+ if homeTab:
+ await homeTab.click()
+ await Tools.System.sleep(1000)
+ return createResponse(True, "已导航到首页")
+ else:
+ return createResponse(False, "无法导航到首页")
+
+
+async def navigate_to_profile(params):
+ console.log("导航到个人主页")
+ if await ensureMain():
+ page = await UINode.getCurrentPage()
+ profileTab = page.findByText("我") or page.findByText("个人主页")
+ if profileTab:
+ await profileTab.click()
+ await Tools.System.sleep(2000)
+ return createResponse(True, "已导航到个人主页")
+ else:
+ return createResponse(False, "未找到个人主页标签")
+ else:
+ return createResponse(False, "无法导航到个人主页")
+
+
+async def navigate_to_search(params):
+ console.log("导航到搜索页面")
+ if await ensureMain():
+ page = await UINode.getCurrentPage()
+ searchTab = page.findByText("搜索") or page.findByContentDesc("搜索")
+ if searchTab:
+ await searchTab.click()
+ await Tools.System.sleep(1500)
+ return createResponse(True, "已导航到搜索页面")
+ else:
+ await Tools.UI.tap(540, 200)
+ await Tools.System.sleep(1500)
+ return createResponse(True, "已打开搜索页面")
+ else:
+ return createResponse(False, "无法导航到搜索页面")
+
+
+async def navigate_to_publish(params):
+ console.log("导航到发布页面")
+ if await ensureMain():
+ page = await UINode.getCurrentPage()
+ publishTab = page.findByText("发布") or page.findByText("+")
+ if publishTab:
+ await publishTab.click()
+ await Tools.System.sleep(2000)
+ return createResponse(True, "已导航到发布页面")
+ else:
+ return createResponse(False, "未找到发布按钮")
+ else:
+ return createResponse(False, "无法导航到发布页面")
+
+
+async def back_to_feed(params):
+ console.log("从帖子详情页返回到信息流")
+ if await isInPostDetail():
+ await Tools.UI.pressKey("KEYCODE_BACK")
+ await Tools.System.sleep(2000)
+ return createResponse(True, "已返回到信息流列表")
+ else:
+ return createResponse(True, "当前不在帖子详情页,无需返回")
+
+
+async def main(params):
+ console.log("=== 小红书智能助手测试 ===")
+ console.log("测试搜索功能...")
+ console.log(json.dumps(await get_post_info({}), indent=2, ensure_ascii=False, default=str))
+
+
+async def wrapToolExecution(func, params):
+ try:
+ result = await func(params)
+ if result:
+ complete(dict(result))
+ else:
+ complete({"success": True, "message": "操作成功完成但无返回数据。"})
+ except Exception as error:
+ console.error(f"Tool {func.__name__} failed unexpectedly", error)
+ complete({
+ "success": False,
+ "message": f"工具执行时发生意外错误: {str(error)}",
+ })
+
+
+def _make_wrapper(func):
+ async def wrapper(params):
+ await wrapToolExecution(func, params)
+ return wrapper
+
+
+exports.browse_home_feed = _make_wrapper(browse_home_feed)
+exports.search_content = _make_wrapper(search_content)
+exports.view_post = _make_wrapper(view_post)
+exports.like_post = _make_wrapper(like_post)
+exports.collect_post = _make_wrapper(collect_post)
+exports.follow_user = _make_wrapper(follow_user)
+exports.unfollow_user = _make_wrapper(unfollow_user)
+exports.comment_post = _make_wrapper(comment_post)
+exports.get_post_info = _make_wrapper(get_post_info)
+exports.publish_post = _make_wrapper(publish_post)
+exports.navigate_to_home = _make_wrapper(navigate_to_home)
+exports.navigate_to_profile = _make_wrapper(navigate_to_profile)
+exports.navigate_to_search = _make_wrapper(navigate_to_search)
+exports.navigate_to_publish = _make_wrapper(navigate_to_publish)
+exports.back_to_feed = _make_wrapper(back_to_feed)
+exports.main = _make_wrapper(main)
diff --git a/operit_py/baidu_map.py b/operit_py/baidu_map.py
new file mode 100644
index 0000000..143ce4a
--- /dev/null
+++ b/operit_py/baidu_map.py
@@ -0,0 +1,801 @@
+# METADATA
+# {
+# "name": "baidu_map",
+# "display_name": { "zh": "百度地图工具", "en": "Baidu Map Tools" },
+# "description": { "zh": "百度地图工具集合,提供AOI(兴趣区域)数据获取接口。通过调用百度地图API,支持按地理范围查询AOI边界坐标,基于位置的路线规划,助力地理信息系统应用开发和空间数据分析。", "en": "A Baidu Maps toolkit that provides AOI (Area of Interest) data access. It supports querying AOI boundary coordinates by geographic range and location-based route planning, useful for GIS development and spatial data analysis." },
+# "enabledByDefault": true,
+# "category": "Map",
+# "tools": [
+# { "name": "search_aoi", "description": { "zh": "搜索百度地图兴趣区域(AOI)信息", "en": "Search AOI (Area of Interest) information from Baidu Maps." }, "parameters": [ { "name": "keyword", "description": { "zh": "搜索关键词,如商场、小区名称等", "en": "Search keyword, e.g. mall name, residential community name, etc." }, "type": "string", "required": true }, { "name": "city_name", "description": { "zh": "城市名称,如'北京',默认全国范围", "en": "City name, e.g. '北京'. Defaults to nationwide." }, "type": "string", "required": false } ] },
+# { "name": "planRoute", "description": { "zh": "智能路线规划,从当前位置到指定目的地,并发返回驾车、步行、公交三种方式的路线规划。", "en": "Smart route planning from current location to a destination, returning driving/walking/transit plans in parallel." }, "parameters": [ { "name": "destination", "description": { "zh": "目的地名称", "en": "Destination name." }, "type": "string", "required": true }, { "name": "city_name", "description": { "zh": "城市名称,辅助目的地查找", "en": "City name to help resolve the destination (optional)." }, "type": "string", "required": false } ] }
+# ]
+# }
+
+import json
+import math
+import time
+import asyncio
+from datetime import datetime
+from urllib.parse import quote, unquote
+
+Error = Exception
+
+# 常用城市编码
+CITY_CODES = {
+ "北京": "131",
+ "上海": "289",
+ "广州": "257",
+ "深圳": "340",
+ "杭州": "179",
+ "南京": "315",
+ "武汉": "218",
+ "成都": "75",
+ "重庆": "132",
+ "西安": "233",
+ "全国": "1" # 默认值
+}
+
+# 请求头配置
+HEADERS = {
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'Cache-Control': 'no-cache',
+ 'Connection': 'keep-alive',
+ 'Host': 'map.baidu.com',
+ 'Pragma': 'no-cache',
+ 'Sec-Fetch-Dest': 'document',
+ 'Sec-Fetch-Mode': 'navigate',
+ 'Sec-Fetch-Site': 'none',
+ 'Sec-Fetch-User': '?1',
+ 'Upgrade-Insecure-Requests': '1',
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36 Edg/136.0.0.0',
+ 'sec-ch-ua': '"Chromium";v="136", "Microsoft Edge";v="136", "Not.A/Brand";v="99"',
+ 'sec-ch-ua-mobile': '?0',
+ 'sec-ch-ua-platform': '"Windows"',
+ 'Referer': 'https://map.baidu.com/'
+}
+
+# 日志级别配置
+LOG_LEVELS = {
+ 'NONE': 0,
+ 'ERROR': 1,
+ 'WARN': 2,
+ 'INFO': 3,
+ 'DEBUG': 4,
+ 'TRACE': 5
+}
+
+# 默认日志级别
+currentLogLevel = LOG_LEVELS['INFO']
+
+
+def setLogLevel(level):
+ global currentLogLevel
+ if level >= LOG_LEVELS['NONE'] and level <= LOG_LEVELS['TRACE']:
+ currentLogLevel = level
+
+
+def logger(level, message, data=None):
+ if level > currentLogLevel:
+ return
+ levelNames = {
+ LOG_LEVELS['ERROR']: '[ERROR]',
+ LOG_LEVELS['WARN']: '[WARN]',
+ LOG_LEVELS['INFO']: '[INFO]',
+ LOG_LEVELS['DEBUG']: '[DEBUG]',
+ LOG_LEVELS['TRACE']: '[TRACE]'
+ }
+ levelName = levelNames.get(level, '[UNKNOWN]')
+ timestamp = datetime.now().isoformat()
+ logMessage = f"{timestamp} {levelName} {message}"
+ console.log(logMessage)
+ if data is not None:
+ console.log(data)
+
+
+def encodeURIComponentSafe(s):
+ try:
+ return quote(s, safe='')
+ except Exception as e:
+ logger(LOG_LEVELS['ERROR'], "编码失败:", e)
+ return s
+
+
+def createHttpClient():
+ return OkHttp.newBuilder() \
+ .connectTimeout(10000) \
+ .readTimeout(30000) \
+ .writeTimeout(15000) \
+ .followRedirects(True) \
+ .build()
+
+
+async def httpGet(url):
+ try:
+ client = createHttpClient()
+ response = await client.newRequest().url(url).method("GET").headers(HEADERS).build().execute()
+ if not response.isSuccessful():
+ raise Error(f"请求失败: {response.statusCode}")
+ try:
+ return json.loads(response.content)
+ except Exception as e:
+ logger(LOG_LEVELS['ERROR'], "解析JSON失败:", e)
+ return response.content
+ except Exception as e:
+ logger(LOG_LEVELS['ERROR'], "网络请求错误:", e)
+ raise e
+
+
+async def getCityCode(cityName):
+ try:
+ logger(LOG_LEVELS['INFO'], f"开始查询城市编码: {cityName}")
+ if cityName in CITY_CODES:
+ logger(LOG_LEVELS['DEBUG'], f"本地映射表中找到城市\"{cityName}\"的编码:", CITY_CODES[cityName])
+ return CITY_CODES[cityName]
+
+ encodedCityName = encodeURIComponentSafe(cityName)
+ url = f"https://map.baidu.com/?newmap=1&qt=s&wd={encodedCityName}&c=1"
+ logger(LOG_LEVELS['INFO'], "发送请求获取城市编码:", url)
+ result = await httpGet(url)
+
+ cityCode = "1"
+
+ if result and result.get("current_city") and result["current_city"].get("code"):
+ cityCode = str(result["current_city"]["code"])
+ logger(LOG_LEVELS['DEBUG'], "从current_city中找到城市编码:", cityCode)
+ elif result and result.get("content") and isinstance(result["content"], list) and len(result["content"]) > 0:
+ for item in result["content"]:
+ if item.get("area_code"):
+ cityCode = str(item["area_code"])
+ logger(LOG_LEVELS['DEBUG'], "从content[].area_code中找到城市编码:", cityCode)
+ break
+ elif item.get("city_id"):
+ cityCode = str(item["city_id"])
+ logger(LOG_LEVELS['DEBUG'], "从content[].city_id中找到城市编码:", cityCode)
+ break
+ elif result and result.get("result") and result["result"].get("code"):
+ cityCode = str(result["result"]["code"])
+ logger(LOG_LEVELS['DEBUG'], "从result.code中找到城市编码:", cityCode)
+ elif result and result.get("result") and result["result"].get("city_id"):
+ cityCode = str(result["result"]["city_id"])
+ logger(LOG_LEVELS['DEBUG'], "从result.city_id中找到城市编码:", cityCode)
+
+ if cityCode == "1":
+ secondUrl = f"https://map.baidu.com/?qt=cur&wd={encodedCityName}"
+ logger(LOG_LEVELS['DEBUG'], "未找到编码,尝试第二个API端点:", secondUrl)
+ try:
+ await Tools.System.sleep(500)
+ secondResult = await httpGet(secondUrl)
+ if secondResult and secondResult.get("current_city") and secondResult["current_city"].get("code"):
+ cityCode = str(secondResult["current_city"]["code"])
+ logger(LOG_LEVELS['DEBUG'], "从第二个API获取到城市编码:", cityCode)
+ except Exception as e:
+ logger(LOG_LEVELS['ERROR'], "第二个API请求失败:", e)
+
+ if cityCode == "1":
+ logger(LOG_LEVELS['INFO'], f"未能找到城市\"{cityName}\"的编码,使用默认编码\"1\"(全国)")
+ else:
+ CITY_CODES[cityName] = cityCode
+ logger(LOG_LEVELS['DEBUG'], f"已将城市\"{cityName}\"的编码{cityCode}添加到临时映射表")
+
+ return cityCode
+ except Exception as error:
+ logger(LOG_LEVELS['ERROR'], "获取城市编码失败:", error)
+ return "1"
+
+
+async def search_aoi(params):
+ try:
+ keyword = params.get("keyword", "")
+ if not keyword:
+ raise Error("关键词不能为空")
+
+ cityCode = "1"
+ if params.get("city_name"):
+ cityCode = await getCityCode(params["city_name"])
+ logger(LOG_LEVELS['DEBUG'], f"城市 \"{params['city_name']}\" 对应的编码:", cityCode)
+
+ encodedKeyword = encodeURIComponentSafe(keyword)
+ url = f"https://map.baidu.com/?newmap=1&qt=s&da_src=searchBox.button&wd={encodedKeyword}&c={cityCode}"
+ logger(LOG_LEVELS['INFO'], f"搜索AOI: {keyword}, 城市名称: {params.get('city_name', '全国')}")
+
+ result = await httpGet(url)
+ dataContent = result.get("content", []) if result else []
+
+ if not dataContent or len(dataContent) == 0:
+ logger(LOG_LEVELS['INFO'], "搜索结果为空或格式不符合预期")
+ return {
+ "success": True,
+ "keyword": keyword,
+ "city_name": params.get("city_name"),
+ "total": 0,
+ "aois": []
+ }
+
+ potentialAois = []
+ for item in dataContent:
+ uid = item.get("uid", "")
+ name = item.get("name", "") or item.get("alias", "")
+ address = item.get("addr", "")
+ area_name = item.get("area_name")
+ phone = item.get("tel")
+
+ typeStr = ""
+ if item.get("cla") and isinstance(item["cla"], list) and len(item["cla"]) > 0:
+ typeStr = ", ".join([cla[1] for cla in item["cla"]])
+ detail_type = item.get("std_tag")
+ tags = item.get("di_tag")
+
+ rating = None
+ comment_count = None
+ overallRating = item.get("overall_rating") or (item.get("ext", {}).get("detail_info", {}) or {}).get("overall_rating")
+ if overallRating:
+ rating = float(overallRating)
+ commentNum = (item.get("ext", {}).get("detail_info", {}) or {}).get("comment_num")
+ if commentNum:
+ comment_count = int(commentNum)
+
+ price = None
+ ticket_info = None
+ detail_info = (item.get("ext", {}) or {}).get("detail_info", {}) or {}
+ if detail_info.get("price"):
+ price = detail_info["price"]
+ if detail_info.get("dk_ticket"):
+ ticketData = detail_info["dk_ticket"]
+ ticket_info = {
+ "title": ticketData.get("title"),
+ "price": ticketData.get("price"),
+ "market_price": ticketData.get("marketprice"),
+ "sold_info": ticketData.get("sold_info"),
+ "booking_tags": ticketData.get("bookingTimeTag", []),
+ "ticket_type": ticketData.get("ticket_type_name")
+ }
+
+ opening_hours = None
+ opening_hours_detail = None
+ business_time = item.get("business_time", {}) or {}
+ if business_time.get("data") and len(business_time["data"]) > 0:
+ timeData = business_time["data"][0]
+ btt = timeData.get("business_time_text", {}) or {}
+ opening_hours = btt.get("common")
+ opening_hours_detail = {
+ "common_hours": btt.get("common"),
+ "festival_hours": btt.get("festival"),
+ "detailed_schedule": timeData.get("common", []),
+ "festival_schedule": timeData.get("festival", {})
+ }
+
+ rankings = []
+ bangdan_head = detail_info.get("bangdan_head", {}) or {}
+ if bangdan_head.get("ranking_show"):
+ rankings = [{
+ "name": rank.get("ranking"),
+ "rank": rank.get("rank_s"),
+ "score": rank.get("score"),
+ "short_name": rank.get("short_name"),
+ "type": rank.get("ranking_type")
+ } for rank in bangdan_head["ranking_show"]]
+
+ events = []
+ if detail_info.get("event_notice"):
+ en = detail_info["event_notice"]
+ events.append({
+ "title": en.get("title"),
+ "content": en.get("content"),
+ "img_url": en.get("img_url"),
+ "start_end": en.get("start_end")
+ })
+
+ shop_hours_simple = item.get("shop_hours_simple")
+ photo_count = int(detail_info["photo_num"]) if detail_info.get("photo_num") else None
+ has_indoor_map = detail_info.get("indoor_map") == '1'
+
+ has_street_view = False
+ street_view_info = None
+ pano = item.get("pano")
+ if isinstance(pano, str) and pano:
+ firstPano = pano.split(';')[0]
+ panoParts = firstPano.split(',')
+ if len(panoParts) >= 2 and panoParts[0]:
+ has_street_view = True
+ street_view_info = {
+ "pid": panoParts[0],
+ "heading": int(panoParts[1] or '0')
+ }
+
+ lng = 0
+ lat = 0
+ if detail_info.get("point"):
+ lng = float(detail_info["point"].get("x", 0))
+ lat = float(detail_info["point"].get("y", 0))
+ elif item.get("x") and item.get("y"):
+ lng = float(item["x"])
+ lat = float(item["y"])
+ elif item.get("point") and item["point"].get("x") and item["point"].get("y"):
+ lng = float(item["point"]["x"])
+ lat = float(item["point"]["y"])
+
+ hasGeoData = bool(
+ (item.get("geo") and len(item["geo"]) > 0) or
+ (detail_info.get("guoke_geo") and detail_info["guoke_geo"].get("geo")) or
+ item.get("geo_type") == 2
+ )
+
+ additional_info = {}
+ if detail_info.get("aoi_src_id"):
+ additional_info["aoi_src_id"] = detail_info["aoi_src_id"]
+ if detail_info.get("navi_update_time"):
+ additional_info["navi_update_time"] = detail_info["navi_update_time"]
+ if detail_info.get("official_url"):
+ additional_info["official_url"] = detail_info["official_url"]
+ if detail_info.get("is_reservable"):
+ additional_info["is_reservable"] = detail_info["is_reservable"] == '1'
+ if detail_info.get("areaid"):
+ additional_info["area_id"] = detail_info["areaid"]
+ if detail_info.get("entrance_price"):
+ additional_info["entrance_price"] = detail_info["entrance_price"]
+ if detail_info.get("free"):
+ additional_info["is_free"] = detail_info["free"]
+
+ detailUrl = f"https://map.baidu.com/?qt=ext&uid={uid}" if uid else ""
+
+ aoiItem = {
+ "uid": uid,
+ "name": name,
+ "address": address,
+ "area_name": area_name,
+ "phone": phone,
+ "tags": tags,
+ "detail_type": detail_type,
+ "rating": rating,
+ "comment_count": comment_count,
+ "price": price,
+ "ticket_info": ticket_info,
+ "opening_hours": opening_hours,
+ "opening_hours_detail": opening_hours_detail,
+ "shop_hours_simple": shop_hours_simple,
+ "photo_count": photo_count,
+ "has_street_view": has_street_view,
+ "street_view_info": street_view_info,
+ "has_indoor_map": has_indoor_map,
+ "rankings": rankings,
+ "events": events,
+ "type": typeStr,
+ "has_geo_data": hasGeoData,
+ "center": {"lng": lng, "lat": lat},
+ "detail_url": detailUrl,
+ "additional_info": additional_info
+ }
+ if currentLogLevel >= LOG_LEVELS['DEBUG']:
+ aoiItem["raw_data"] = {
+ "uid": item.get("uid"),
+ "name": item.get("name"),
+ "addr": item.get("addr"),
+ "x": item.get("x"),
+ "y": item.get("y"),
+ "geo_type": item.get("geo_type")
+ }
+ potentialAois.append(aoiItem)
+
+ logger(LOG_LEVELS['DEBUG'], f"找到{len(potentialAois)}个AOI结果")
+ return {
+ "success": True,
+ "keyword": keyword,
+ "city_name": params.get("city_name"),
+ "total": len(potentialAois),
+ "aois": potentialAois
+ }
+ except Exception as error:
+ logger(LOG_LEVELS['ERROR'], "[search_aoi] 错误:", error)
+ import traceback
+ logger(LOG_LEVELS['ERROR'], "错误堆栈:", traceback.format_exc())
+ return {
+ "success": False,
+ "message": f"搜索AOI失败: {error}",
+ "keyword": params.get("keyword"),
+ "city_name": params.get("city_name"),
+ "total": 0,
+ "aois": []
+ }
+
+
+async def get_aoi_boundary(params):
+ try:
+ uid = params.get("uid", "")
+ if not uid:
+ raise Error("AOI的UID不能为空")
+
+ url = f"https://map.baidu.com/?qt=ext&uid={uid}"
+ logger(LOG_LEVELS['INFO'], f"获取AOI边界: {uid}")
+ result = await httpGet(url)
+ logger(LOG_LEVELS['DEBUG'], "AOI边界结果结构:", list(result.keys()) if result else {})
+
+ if not result or not result.get("content") or (isinstance(result.get("content"), list) and len(result["content"]) == 0):
+ return {"success": False, "message": "未找到AOI边界数据", "uid": uid}
+
+ content = result["content"][0] if isinstance(result["content"], list) else result["content"]
+ if not content:
+ return {"success": False, "message": "AOI内容数据为空", "uid": uid}
+
+ geoData = content.get("geo")
+ if not geoData and content.get("ext") and content["ext"].get("geo"):
+ geoData = content["ext"]["geo"]
+ elif not geoData and content.get("geodata"):
+ geoData = content["geodata"]
+ elif not geoData and content.get("guoke_geo") and content["guoke_geo"].get("geo"):
+ geoData = content["guoke_geo"]["geo"]
+
+ boundary = []
+ if isinstance(geoData, str):
+ try:
+ parts = geoData.split('|')
+ if len(parts) >= 3:
+ boundaryPart = parts[2]
+ coordinatePairs = boundaryPart.split(',')
+ i = 0
+ while i < len(coordinatePairs) - 1:
+ lng = float(coordinatePairs[i])
+ lat = float(coordinatePairs[i + 1])
+ if not (math.isnan(lng) or math.isnan(lat)):
+ boundary.append({"lng": lng, "lat": lat})
+ i += 2
+ logger(LOG_LEVELS['DEBUG'], f"从geo字符串解析到{len(boundary)}个边界点")
+ except Exception as e:
+ logger(LOG_LEVELS['ERROR'], "解析geo字符串失败:", e)
+ elif isinstance(geoData, list):
+ for point in geoData:
+ if isinstance(point, list):
+ boundary.append({"lng": float(point[0] or 0), "lat": float(point[1] or 0)})
+ else:
+ boundary.append({"lng": float(point.get("x", 0) or 0), "lat": float(point.get("y", 0) or 0)})
+ logger(LOG_LEVELS['DEBUG'], f"解析到{len(boundary)}个边界点")
+
+ resultObj = {
+ "success": True,
+ "uid": uid,
+ "name": content.get("name", ""),
+ "address": content.get("addr", ""),
+ "center": {
+ "lng": float(content.get("x") or (content.get("point", {}) or {}).get("x") or '0'),
+ "lat": float(content.get("y") or (content.get("point", {}) or {}).get("y") or '0')
+ },
+ "boundary": boundary,
+ "point_count": len(boundary)
+ }
+ if currentLogLevel >= LOG_LEVELS['DEBUG']:
+ resultObj["raw_data"] = {
+ "uid": content.get("uid"),
+ "name": content.get("name"),
+ "addr": content.get("addr"),
+ "x": content.get("x"),
+ "y": content.get("y"),
+ "geo_type": content.get("geo_type")
+ }
+ return resultObj
+ except Exception as error:
+ logger(LOG_LEVELS['ERROR'], "[get_aoi_boundary] 错误:", error)
+ import traceback
+ logger(LOG_LEVELS['ERROR'], "错误堆栈:", traceback.format_exc())
+ return {"success": False, "message": f"获取AOI边界失败: {error}", "uid": params.get("uid")}
+
+
+def formatAoiResultAsText(aoiResult):
+ if not aoiResult.get("success"):
+ return f"AOI搜索失败: {aoiResult.get('message', '未知错误')}"
+
+ output = "=== AOI搜索结果 ===\n"
+ output += f"搜索关键词: {aoiResult['keyword']}\n"
+ output += f"搜索城市: {aoiResult.get('city_name', '全国')}\n"
+ output += f"找到结果: {aoiResult['total']} 个\n\n"
+
+ if aoiResult.get("aois") and len(aoiResult["aois"]) > 0:
+ for index, aoi in enumerate(aoiResult["aois"]):
+ output += f"--- 结果 {index + 1} ---\n"
+ output += f"名称: {aoi['name']}\n"
+ output += f"类型: {aoi.get('type', '未知')}\n"
+ output += f"地址: {aoi['address']}\n"
+ if aoi.get("area_name"):
+ output += f"所属区域: {aoi['area_name']}\n"
+ if aoi.get("phone"):
+ output += f"联系电话: {aoi['phone']}\n"
+ if aoi.get("tags"):
+ output += f"标签: {aoi['tags']}\n"
+ if aoi.get("rating") is not None:
+ output += f"评分: {aoi['rating']}/5.0"
+ if aoi.get("comment_count") is not None:
+ output += f" ({aoi['comment_count']}条评论)"
+ output += "\n"
+ if aoi.get("price"):
+ output += f"价格信息: {aoi['price']}\n"
+ if aoi.get("ticket_info"):
+ output += "门票信息:\n"
+ ti = aoi["ticket_info"]
+ if ti.get("title"):
+ output += f" - 门票名称: {ti['title']}\n"
+ if ti.get("price"):
+ output += f" - 门票价格: {ti['price']}\n"
+ if ti.get("market_price"):
+ output += f" - 市场价: {ti['market_price']}\n"
+ if ti.get("sold_info"):
+ output += f" - 销售信息: {ti['sold_info']}\n"
+ if aoi.get("opening_hours"):
+ output += f"开放时间: {aoi['opening_hours']}\n"
+ elif aoi.get("shop_hours_simple"):
+ output += f"当前状态: {aoi['shop_hours_simple']}\n"
+ if aoi.get("opening_hours_detail"):
+ detail = aoi["opening_hours_detail"]
+ if detail.get("common_hours"):
+ output += f"常规时间: {detail['common_hours']}\n"
+ if detail.get("festival_hours"):
+ output += f"节假日时间: {detail['festival_hours']}\n"
+ if aoi.get("photo_count"):
+ output += f"照片数量: {aoi['photo_count']} 张\n"
+ features = []
+ if aoi.get("has_street_view"):
+ features.append("街景")
+ if aoi.get("has_indoor_map"):
+ features.append("室内地图")
+ if aoi.get("has_geo_data"):
+ features.append("边界数据")
+ if len(features) > 0:
+ output += f"可用功能: {', '.join(features)}\n"
+ if aoi.get("rankings") and len(aoi["rankings"]) > 0:
+ output += "排行榜信息:\n"
+ for rank in aoi["rankings"][:3]:
+ if rank.get("name") and rank.get("rank"):
+ output += f" - {rank['name']}: {rank['rank']}"
+ if rank.get("score"):
+ output += f" (评分: {rank['score']})"
+ output += "\n"
+ if aoi.get("events") and len(aoi["events"]) > 0 and aoi["events"][0].get("title"):
+ output += "当前活动:\n"
+ for event in aoi["events"]:
+ if event.get("title"):
+ output += f" - {event['title']}"
+ if event.get("start_end"):
+ output += f" ({event['start_end']})"
+ output += "\n"
+ if event.get("content"):
+ output += f" {event['content']}\n"
+ output += f"坐标: 经度 {aoi['center']['lng']}, 纬度 {aoi['center']['lat']}\n"
+ output += f"详情链接: {aoi['detail_url']}\n"
+ if aoi.get("additional_info") and len(aoi["additional_info"]) > 0:
+ output += "其他信息: "
+ info = []
+ for key, value in aoi["additional_info"].items():
+ if key == 'is_free' and value == 2:
+ info.append("收费景点")
+ elif key == 'is_free' and value == 1:
+ info.append("免费景点")
+ elif key == 'area_id':
+ info.append(f"区域ID: {value}")
+ else:
+ info.append(f"{key}: {value}")
+ output += ", ".join(info) + "\n"
+ output += "\n"
+ if aoiResult.get("boundary"):
+ output += "=== 边界信息 ===\n"
+ output += f"边界点数: {aoiResult['boundary'].get('point_count', 0)}\n"
+ if aoiResult["boundary"].get("center"):
+ output += f"中心坐标: 经度 {aoiResult['boundary']['center']['lng']}, 纬度 {aoiResult['boundary']['center']['lat']}\n"
+ output += "\n"
+ return output
+
+
+def formatRouteResultAsText(routeResult):
+ if not routeResult.get("success"):
+ return f"路线规划失败: {routeResult.get('message', '未知错误')}"
+ output = "=== 路线规划结果 ===\n"
+ if routeResult.get("current_location"):
+ output += f"当前位置: 经度 {routeResult['current_location']['lng']}, 纬度 {routeResult['current_location']['lat']}\n"
+ if routeResult["current_location"].get("address"):
+ output += f"当前地址: {routeResult['current_location']['address']}\n"
+ if routeResult.get("destination"):
+ output += f"目的地: {routeResult['destination']['name']}\n"
+ output += f"目的地址: {routeResult['destination']['address']}\n"
+ output += f"目的坐标: 经度 {routeResult['destination']['location']['lng']}, 纬度 {routeResult['destination']['location']['lat']}\n"
+ output += "\n"
+ if routeResult.get("all_routes"):
+ output += "=== 所有交通方式 ===\n"
+ if routeResult["all_routes"].get("driving"):
+ driving = routeResult["all_routes"]["driving"]
+ output += "驾车路线:\n"
+ output += f" 距离: {driving['estimated_distance']}\n"
+ output += f" 时间: {driving['estimated_duration']}\n"
+ output += f" 建议: {driving['suggestion']}\n\n"
+ if routeResult["all_routes"].get("walking"):
+ walking = routeResult["all_routes"]["walking"]
+ output += "步行路线:\n"
+ output += f" 距离: {walking['estimated_distance']}\n"
+ output += f" 时间: {walking['estimated_duration']}\n"
+ output += f" 建议: {walking['suggestion']}\n\n"
+ if routeResult["all_routes"].get("transit"):
+ transit = routeResult["all_routes"]["transit"]
+ output += "公共交通:\n"
+ output += f" 距离: {transit['estimated_distance']}\n"
+ output += f" 时间: {transit['estimated_duration']}\n"
+ output += f" 建议: {transit['suggestion']}\n\n"
+ return output
+
+
+async def getCurrentLocation():
+ try:
+ logger(LOG_LEVELS['INFO'], "正在获取用户当前位置...")
+ locationResult = await Tools.System.getLocation()
+ if not locationResult:
+ logger(LOG_LEVELS['ERROR'], "获取位置失败:", "未知错误")
+ return None
+ return {"lng": locationResult.longitude, "lat": locationResult.latitude}
+ except Exception as error:
+ logger(LOG_LEVELS['ERROR'], "获取位置出错:", str(error))
+ return None
+
+
+def convertMercatorToLatLng(mercatorLng, mercatorLat):
+ lng = (mercatorLng / 20037508.34) * 180
+ lat = (mercatorLat / 20037508.34) * 180
+ lat = (180 / math.pi) * (2 * math.atan(math.exp(lat * math.pi / 180)) - math.pi / 2)
+ return {"lng": lng, "lat": lat}
+
+
+def calculateDistance(lat1, lng1, lat2, lng2):
+ R = 6371000
+ dLat = (lat2 - lat1) * math.pi / 180
+ dLng = (lng2 - lng1) * math.pi / 180
+ a = math.sin(dLat / 2) * math.sin(dLat / 2) + \
+ math.cos(lat1 * math.pi / 180) * math.cos(lat2 * math.pi / 180) * \
+ math.sin(dLng / 2) * math.sin(dLng / 2)
+ c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
+ return R * c
+
+
+def formatDuration(minutes):
+ if minutes < 60:
+ return f"约{math.ceil(minutes)}分钟"
+ hours = int(minutes / 60)
+ remainingMinutes = math.ceil(minutes % 60)
+ if remainingMinutes == 0:
+ return f"约{hours}小时"
+ return f"约{hours}小时{remainingMinutes}分钟"
+
+
+def estimateDuration(distance, mode):
+ minutes = 0
+ distanceInKm = distance / 1000
+ if mode == "walking":
+ minutes = distanceInKm * 15
+ elif mode == "transit":
+ if distanceInKm < 10:
+ minutes = distanceInKm * 4
+ elif distanceInKm < 100:
+ minutes = distanceInKm * 2
+ else:
+ minutes = distanceInKm * 1
+ else: # driving
+ if distanceInKm < 10:
+ minutes = distanceInKm * 2
+ elif distanceInKm < 100:
+ minutes = distanceInKm * 1.2
+ else:
+ minutes = distanceInKm * 0.75
+ return formatDuration(minutes)
+
+
+def getSuggestion(distance, mode):
+ if distance < 500:
+ return "目的地非常近,步行即可到达"
+ elif distance < 2000:
+ return "距离适中,可步行或乘坐短途交通工具"
+ elif distance < 5000:
+ return "距离较远,建议使用公共交通工具"
+ else:
+ return "目的地较远,建议驾车或使用公共交通工具"
+
+
+async def planRoute(params):
+ try:
+ currentLocation = await getCurrentLocation()
+ if not currentLocation:
+ return {"success": False, "message": "无法获取当前位置信息"}
+
+ searchResults = await search_aoi({
+ "keyword": params["destination"],
+ "city_name": params.get("city_name")
+ })
+
+ if not searchResults.get("success") or not searchResults.get("aois") or len(searchResults["aois"]) == 0:
+ return {
+ "success": False,
+ "message": f"未能找到目的地: {params['destination']}",
+ "current_location": currentLocation
+ }
+
+ destination = searchResults["aois"][0]
+ destLocation = destination["center"]
+ if not destLocation or not destLocation.get("lng") or not destLocation.get("lat"):
+ return {
+ "success": False,
+ "message": f"目的地坐标信息无效: {params['destination']}",
+ "current_location": currentLocation
+ }
+
+ destLatLng = convertMercatorToLatLng(destLocation["lng"], destLocation["lat"])
+ logger(LOG_LEVELS['DEBUG'], f"目的地 \"{destination['name']}\" 墨卡托坐标:", destLocation)
+ logger(LOG_LEVELS['DEBUG'], "转换后的经纬度:", destLatLng)
+
+ distance = calculateDistance(currentLocation["lat"], currentLocation["lng"], destLatLng["lat"], destLatLng["lng"])
+
+ async def getRouteDetailsForMode(mode):
+ cityCode = "1"
+ if params.get("city_name"):
+ cityCode = await getCityCode(params["city_name"])
+ navUrl = f"https://api.map.baidu.com/direction?origin={currentLocation['lat']},{currentLocation['lng']}&destination={destLatLng['lat']},{destLatLng['lng']}&mode={mode}®ion={cityCode}&output=html"
+ return {
+ "estimated_distance": f"{(distance / 1000):.2f}公里",
+ "estimated_duration": estimateDuration(distance, mode),
+ "transport_mode": mode,
+ "navigation_url": navUrl,
+ "suggestion": getSuggestion(distance, mode)
+ }
+
+ logger(LOG_LEVELS['INFO'], "获取所有模式的路线规划...")
+ modes = ["driving", "walking", "transit"]
+ routesResults = await asyncio.gather(*[getRouteDetailsForMode(mode) for mode in modes])
+
+ all_routes = {
+ "driving": routesResults[0],
+ "walking": routesResults[1],
+ "transit": routesResults[2]
+ }
+
+ return {
+ "success": True,
+ "current_location": currentLocation,
+ "destination": {
+ "name": destination["name"],
+ "address": destination["address"],
+ "location": destLatLng
+ },
+ "all_routes": all_routes
+ }
+ except Exception as error:
+ logger(LOG_LEVELS['ERROR'], "[planRoute] 错误:", error)
+ return {"success": False, "message": f"路线规划失败: {error}"}
+
+
+async def main():
+ output = ""
+ output += "========== 百度地图格式化函数测试 ==========\n\n"
+ try:
+ output += "[1] 测试AOI搜索格式化...\n"
+ aoiResult = await search_aoi({"keyword": "长安大学", "city_name": "西安"})
+ output += formatAoiResultAsText(aoiResult) + "\n"
+ await Tools.System.sleep(1000)
+ output += "[3] 测试路径规划格式化...\n"
+ routeResult = await planRoute({"destination": "长安大学", "city_name": "西安"})
+ output += formatRouteResultAsText(routeResult) + "\n"
+ output += "========== 格式化测试完成 ==========\n"
+ logger(LOG_LEVELS['INFO'], output)
+ return output
+ except Exception as error:
+ return f"测试过程中发生错误: {error}"
+
+
+def wrap(coreFunction):
+ async def wrapped(params):
+ result = await coreFunction(params)
+ complete(result)
+ return result
+ return wrapped
+
+
+# 导出
+async def _search_aoi_wrapper(param):
+ return formatAoiResultAsText(await search_aoi(param))
+
+async def _planRoute_wrapper(param):
+ return formatRouteResultAsText(await planRoute(param))
+
+exports.search_aoi = wrap(_search_aoi_wrapper)
+exports.planRoute = wrap(_planRoute_wrapper)
+exports.main = wrap(main)
diff --git a/operit_py/beeimg_image_uploader.py b/operit_py/beeimg_image_uploader.py
new file mode 100644
index 0000000..f0e0967
--- /dev/null
+++ b/operit_py/beeimg_image_uploader.py
@@ -0,0 +1,176 @@
+# METADATA
+# {
+# "name": "beeimg_image_uploader_v2",
+#
+# "display_name": {
+# "zh": "Beeimg 图片上传器 V2",
+# "en": "Beeimg Image Uploader V2"
+# },
+# "description": {
+# "zh": "BeeIMG(https://beeimg.com/)工具将本地图片上传到图床并返回图片url,配合图片生成工具实现图生图(图生图务必开启)。",
+# "en": "Upload a local image to BeeIMG (https://beeimg.com/) and return a public image URL. Useful for image-to-image workflows (make sure img2img is enabled)."
+# },
+# "env": [
+# "BEEIMG_API_KEY"
+# ],
+# "category": "Media",
+# "tools": [
+# {
+# "name": "upload_image",
+# "description": {
+# "zh": "使用 multipart 上传将本地图片上传到 BeeIMG 图床并返回图片 url。",
+# "en": "Upload a local image to BeeIMG via multipart upload and return the image URL."
+# },
+# "parameters": [
+# { "name": "file_path", "description": { "zh": "要上传的图片文件绝对路径 (建议使用 /sdcard/ 开头的完整路径)。", "en": "Absolute path of the image file to upload (recommended: full path starting with /sdcard/)." }, "type": "string", "required": true },
+# { "name": "album_id", "description": { "zh": "相册ID (可选)。", "en": "Album ID (optional)." }, "type": "string", "required": false },
+# { "name": "privacy", "description": { "zh": "隐私设置,'public' 或 'private' (可选)。", "en": "Privacy setting: 'public' or 'private' (optional)." }, "type": "string", "required": false }
+# ]
+# }
+# ]
+# }
+
+import json
+import traceback
+
+API_ENDPOINT = "https://beeimg.com/api/upload/file/json/"
+
+
+def isRecord(value):
+ return isinstance(value, dict)
+
+
+def getErrorMessage(error):
+ if isinstance(error, Exception):
+ return str(error)
+ return str(error)
+
+
+def getErrorStack(error):
+ if isinstance(error, Exception):
+ return traceback.format_exc()
+ return None
+
+
+def getApiKey():
+ return getEnv("BEEIMG_API_KEY") or ""
+
+
+def guessMimeTypeFromPath(filePath):
+ lower = filePath.lower()
+ if lower.endswith(".png"):
+ return "image/png"
+ if lower.endswith(".jpg") or lower.endswith(".jpeg"):
+ return "image/jpeg"
+ if lower.endswith(".webp"):
+ return "image/webp"
+ if lower.endswith(".gif"):
+ return "image/gif"
+ return "application/octet-stream"
+
+
+def safeJsonParseLoose(text):
+ trimmed = (text or "").strip()
+ if not trimmed:
+ return None
+ try:
+ return json.loads(trimmed)
+ except Exception as e:
+ start = trimmed.find("{")
+ end = trimmed.rfind("}")
+ if start != -1 and end != -1 and end > start:
+ return json.loads(trimmed[start:end + 1])
+ raise e
+
+
+async def upload_image(params):
+ file_path = params.get("file_path") if params else None
+ album_id = params.get("album_id") if params else None
+ privacy = params.get("privacy") if params else None
+
+ if not file_path or len(str(file_path).strip()) == 0:
+ raise Exception("参数 file_path 不能为空。")
+
+ # 1. 检查文件是否存在
+ fileExists = await Tools.Files.exists(file_path)
+ if not fileExists["exists"]:
+ raise Exception(f"文件未找到: {file_path} (请尝试使用 /sdcard/ 开头的绝对路径)")
+
+ # 2. 准备参数
+ apiKey = getApiKey()
+ if not apiKey:
+ raise Exception("BEEIMG_API_KEY 未配置,请在环境变量中设置 BeeIMG 的 API Key。")
+
+ form_data = {
+ "apikey": apiKey
+ }
+ if album_id:
+ form_data["albumid"] = str(album_id)
+ if privacy:
+ form_data["privacy"] = str(privacy)
+
+ # 3. 发起 multipart 上传
+ console.log("正在执行上传...")
+
+ resp = await Tools.Net.uploadFile({
+ "url": API_ENDPOINT,
+ "method": "POST",
+ "headers": {
+ # 尽量模拟浏览器 UA,降低被风控概率
+ "User-Agent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36"
+ },
+ "form_data": form_data,
+ "files": [
+ {
+ "field_name": "file",
+ "file_path": file_path,
+ "content_type": guessMimeTypeFromPath(file_path)
+ }
+ ]
+ })
+
+ if resp["statusCode"] < 200 or resp["statusCode"] >= 300:
+ raise Exception(f"上传请求失败 (HTTP {resp['statusCode']})。可能是网络问题或 IP 被屏蔽。\n响应: {resp['content']}")
+
+ responseText = (resp.get("content") or "").strip()
+ if not responseText:
+ raise Exception("上传失败:服务器返回了空内容。请检查网络连接或尝试关闭 VPN。")
+
+ try:
+ jsonResponse = safeJsonParseLoose(responseText)
+ except Exception as e:
+ raise Exception(f"API 响应解析失败。服务器返回内容不是 JSON:\n{responseText[:200]}...")
+
+ files = jsonResponse["files"] if isRecord(jsonResponse) and isRecord(jsonResponse.get("files")) else None
+ ok = bool(files) and (files.get("status") == "Success" or files.get("code") == "200" or files.get("code") == 200)
+ url = files.get("url") if files else None
+
+ if ok and (isinstance(url, str) or isinstance(url, (int, float))) and len(str(url).strip()) > 0:
+ return {
+ "url": str(url),
+ "thumbnail_url": str(files["thumbnail_url"]) if files and files.get("thumbnail_url") else None,
+ "page_url": str(files["view_url"]) if files and files.get("view_url") else None,
+ "details": f"上传成功! URL: {str(url)}"
+ }
+ else:
+ if files:
+ errMsg = files.get("status") or files.get("message") or json.dumps(files, ensure_ascii=False)
+ else:
+ errMsg = "未知错误"
+ raise Exception(f"BeeIMG API 报错: {errMsg}")
+
+
+async def wrap(func, params):
+ try:
+ result = await func(params)
+ complete({"success": True, "message": "图片上传成功", "data": result})
+ except Exception as error:
+ console.error(f"Error: {getErrorMessage(error)}")
+ complete({"success": False, "message": getErrorMessage(error), "error_stack": getErrorStack(error)})
+
+
+async def upload_image_wrapper(params):
+ await wrap(upload_image, params)
+
+
+exports.upload_image = upload_image_wrapper
diff --git a/operit_py/bilibili_tools.py b/operit_py/bilibili_tools.py
new file mode 100644
index 0000000..06f1357
--- /dev/null
+++ b/operit_py/bilibili_tools.py
@@ -0,0 +1,697 @@
+# METADATA
+# {
+# "name": "bilibili_tools",
+# "display_name": {"zh": "Bilibili 工具集", "en": "Bilibili Toolkit"},
+# "description": {"zh": "提供B站视频信息分析功能,包括获取字幕、弹幕、评论和搜索视频。", "en": "Analyze Bilibili video information, including subtitles, danmaku, comments, and video search."},
+# "enabledByDefault": True,
+# "category": "Media",
+# "tools": [
+# {"name": "get_subtitles", "description": {"zh": "从Bilibili视频中获取字幕。", "en": "Get subtitles from a Bilibili video."}, "parameters": [
+# {"name": "url", "description": {"zh": "Bilibili视频URL,例如:https://www.bilibili.com/video/BV1x341177NN", "en": "Bilibili video URL, e.g. https://www.bilibili.com/video/BV1x341177NN"}, "type": "string", "required": True}
+# ]},
+# {"name": "get_danmaku", "description": {"zh": "从Bilibili视频中获取弹幕。", "en": "Get danmaku (bullet comments) from a Bilibili video."}, "parameters": [
+# {"name": "url", "description": {"zh": "Bilibili视频URL,例如:https://www.bilibili.com/video/BV1x341177NN", "en": "Bilibili video URL, e.g. https://www.bilibili.com/video/BV1x341177NN"}, "type": "string", "required": True},
+# {"name": "count", "description": {"zh": "要获取的弹幕数量,默认500,最多1000", "en": "Number of danmaku items to fetch (default: 500, max: 1000)."}, "type": "number", "required": False}
+# ]},
+# {"name": "get_comments", "description": {"zh": "从Bilibili视频中获取热门评论。", "en": "Get hot comments from a Bilibili video."}, "parameters": [
+# {"name": "url", "description": {"zh": "Bilibili视频URL,例如:https://www.bilibili.com/video/BV1x341177NN", "en": "Bilibili video URL, e.g. https://www.bilibili.com/video/BV1x341177NN"}, "type": "string", "required": True}
+# ]},
+# {"name": "search_videos", "description": {"zh": "在Bilibili上搜索视频。", "en": "Search videos on Bilibili."}, "parameters": [
+# {"name": "keyword", "description": {"zh": "要搜索的关键词", "en": "Keyword to search for."}, "type": "string", "required": True},
+# {"name": "page", "description": {"zh": "页码,默认为1", "en": "Page number (default: 1)."}, "type": "number", "required": False},
+# {"name": "count", "description": {"zh": "返回结果的数量,默认10,最多20", "en": "Number of results to return (default: 10, max: 20)."}, "type": "number", "required": False}
+# ]},
+# {"name": "get_user_info", "description": {"zh": "获取B站用户的基本信息。", "en": "Get basic information for a Bilibili user."}, "parameters": [
+# {"name": "mid", "description": {"zh": "用户的数字ID (UID)", "en": "User numeric ID (UID)."}, "type": "number", "required": True}
+# ]}
+# ]
+# }
+
+# 参考了https://github.com/vruses/bili-api-interceptor/blob/master/dist/bili-api-interceptor.user.js 特此感谢
+
+import json
+import re
+import time
+import math
+import random
+import hashlib
+import base64
+import zlib
+from urllib.parse import quote, unquote
+
+Error = Exception
+
+# Bilibili API endpoints
+API_GET_VIEW_INFO = "https://api.bilibili.com/x/web-interface/view"
+API_GET_SUBTITLE_LIST = "https://api.bilibili.com/x/player/v2"
+API_GET_DANMAKU = "https://api.bilibili.com/x/v1/dm/list.so"
+API_GET_COMMENTS = "https://api.bilibili.com/x/v2/reply/wbi/main"
+API_SEARCH = "https://api.bilibili.com/x/web-interface/search/all/v2"
+API_NAV = "https://api.bilibili.com/x/web-interface/nav"
+API_GET_USER_INFO = "https://api.bilibili.com/x/space/wbi/acc/info"
+API_GET_RELATION_STAT = "https://api.bilibili.com/x/relation/stat"
+
+client = OkHttp.newClient()
+SESSDATA = None
+wbiKeys = None
+buvid3 = None
+
+
+def generateBuvid3():
+ result = []
+ template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'
+ for c in template:
+ r = int(random.random() * 16)
+ if c == 'x':
+ v = r
+ elif c == 'y':
+ v = (r & 0x3) | 0x8
+ else:
+ v = c
+ result.append(format(v, 'x') if c in 'xy' else c)
+ return ''.join(result)
+
+
+async def init():
+ global SESSDATA, wbiKeys
+ if SESSDATA is None:
+ # SESSDATA 是B站的Cookie,用于访问一些需要登录的API。
+ # 为了简单起见,这里我们不作强制要求。
+ # 如果遇到请求失败的情况,可以尝试手动在这里设置你的SESSDATA值。
+ # 例如: SESSDATA = "your_sessdata_cookie_value";
+ SESSDATA = ""
+ if not SESSDATA:
+ console.log("SESSDATA 未设置,部分需要登录的请求可能会失败。")
+ # Pre-fetch WBI keys if not already available.
+ if not wbiKeys:
+ wbiKeys = await get_wbi_keys()
+
+
+def getHeaders():
+ global buvid3
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36',
+ 'Referer': 'https://www.bilibili.com/',
+ }
+
+ if not buvid3:
+ buvid3 = generateBuvid3()
+
+ cookies = [f'buvid3={buvid3}']
+ if SESSDATA:
+ cookies.append(f'SESSDATA={SESSDATA}')
+ headers['Cookie'] = '; '.join(cookies)
+
+ return headers
+
+
+def unescapeXml(text):
+ return (
+ text.replace('<', '<')
+ .replace('>', '>')
+ .replace('&', '&')
+ .replace('"', '"')
+ .replace(''', "'")
+ )
+
+
+mixinKeyEncTab = [
+ 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
+ 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13,
+ 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4,
+ 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52,
+]
+
+
+def getMixinKey(orig):
+ return ''.join([orig[n] for n in mixinKeyEncTab])[:32]
+
+
+def encWbi(params, img_key, sub_key):
+ mixin_key = getMixinKey(img_key + sub_key)
+ curr_time = round(time.time())
+ chr_filter = r"[!'()*]"
+
+ new_params = dict(params)
+ new_params['wts'] = curr_time
+
+ query = '&'.join(
+ [f"{quote(key)}={quote(re.sub(chr_filter, '', str(new_params[key])))}" for key in sorted(new_params.keys())]
+ )
+
+ w_rid = hashlib.md5((query + mixin_key).encode('utf-8')).hexdigest()
+ return f"{query}&w_rid={w_rid}"
+
+
+async def get_wbi_keys():
+ try:
+ response = await client.get(API_NAV, getHeaders())
+ if not response.isSuccessful():
+ console.error("Failed to get WBI keys, status: " + str(response.statusCode))
+ return None
+ navData = response.json()
+
+ wbi_img = navData.get("data", {}).get("wbi_img", {}) if isinstance(navData.get("data"), dict) else {}
+ if not wbi_img.get("img_url") or not wbi_img.get("sub_url"):
+ console.error("Failed to get WBI keys: " + str(navData.get("message") or "No wbi_img in response"))
+ return None
+
+ imgUrl = wbi_img["img_url"]
+ subUrl = wbi_img["sub_url"]
+
+ img_key = imgUrl[imgUrl.rfind('/') + 1:imgUrl.rfind('.')]
+ sub_key = subUrl[subUrl.rfind('/') + 1:subUrl.rfind('.')]
+
+ return {"img_key": img_key, "sub_key": sub_key}
+ except Exception as e:
+ console.error(f"Error fetching WBI keys: {str(e)}")
+ return None
+
+
+async def extract_bvid(url):
+ match = re.search(r"BV[a-zA-Z0-9_]+", url)
+ if match:
+ return match.group(0)
+
+ if 'b23.tv' in url:
+ try:
+ request = client.newRequest().url(url).method("HEAD").build()
+ response = await request.execute()
+ finalUrl = response.raw.url
+ if finalUrl:
+ match = re.search(r"BV[a-zA-Z0-9_]+", finalUrl)
+ if match:
+ return match.group(0)
+ except Exception as e:
+ console.error(f"Error resolving short URL: {e}")
+ return None
+
+
+async def get_video_basic_info(bvid):
+ try:
+ url = f"{API_GET_VIEW_INFO}?bvid={bvid}"
+ response = await client.get(url, getHeaders())
+ if not response.isSuccessful():
+ return {"aid": None, "cid": None, "error": f"Failed to get video info, status: {response.statusCode}"}
+ data = response.json()
+ if data.get("code") != 0:
+ return {"aid": None, "cid": None, "error": f"Failed to get video info: {data.get('message')}"}
+ videoData = data.get("data", {})
+ return {"aid": videoData.get("aid"), "cid": videoData.get("cid"), "error": None}
+ except Exception as e:
+ return {"aid": None, "cid": None, "error": f"Failed to fetch video details: {str(e)}"}
+
+
+async def get_subtitles_from_api(aid, cid):
+ subtitles = []
+ try:
+ url = f"{API_GET_SUBTITLE_LIST}?aid={aid}&cid={cid}"
+ response = await client.get(url, getHeaders())
+ if not response.isSuccessful():
+ return {"subtitles": [], "error": f"Could not fetch subtitles list, status: {response.statusCode}"}
+ subtitleListData = response.json()
+ sub_data = subtitleListData.get("data", {}) if isinstance(subtitleListData.get("data"), dict) else {}
+ sub_subtitles = sub_data.get("subtitle", {}).get("subtitles", []) if isinstance(sub_data.get("subtitle"), dict) else []
+ if subtitleListData.get("code") == 0 and sub_subtitles:
+ for sub_meta in sub_subtitles:
+ if sub_meta.get("subtitle_url"):
+ try:
+ subtitle_json_url = f"https:{sub_meta['subtitle_url']}"
+ sub_content_response = await client.get(subtitle_json_url, getHeaders())
+ if sub_content_response.isSuccessful():
+ sub_content = sub_content_response.json()
+ subtitle_body = sub_content.get("body") or []
+ content_list = [item.get("content", '') for item in subtitle_body]
+ subtitles.append({
+ "lan": sub_meta.get("lan"),
+ "content": content_list,
+ })
+ except Exception as e:
+ console.error(f"Could not fetch or parse subtitle content from {sub_meta.get('subtitle_url')}: {str(e)}")
+ return {"subtitles": subtitles, "error": None}
+ except Exception as e:
+ return {"subtitles": [], "error": f"Could not fetch subtitles: {str(e)}"}
+
+
+async def get_danmaku_from_api(cid, count):
+ danmaku_list = []
+ try:
+ url = f"{API_GET_DANMAKU}?oid={cid}"
+ response = await client.get(url, getHeaders())
+
+ if not response.isSuccessful():
+ return {"danmaku": [], "error": f"Failed to get danmaku, status: {response.statusCode}"}
+
+ compressedData = response.bodyAsBase64()
+ console.log("base64" + str(compressedData))
+ if not compressedData:
+ return {"danmaku": [], "error": None}
+
+ # pako.inflate 降级为 Python zlib 解压
+ raw_bytes = base64.b64decode(compressedData)
+ try:
+ decompressed = zlib.decompress(raw_bytes, -zlib.MAX_WBITS)
+ except Exception:
+ decompressed = zlib.decompress(raw_bytes)
+ danmaku_content = decompressed.decode('utf-8', errors='ignore')
+
+ regex = re.compile(r'(.*?)', re.DOTALL)
+ for match in regex.finditer(danmaku_content):
+ if len(danmaku_list) >= count:
+ break
+ danmaku_list.append(unescapeXml(match.group(1)))
+
+ return {"danmaku": danmaku_list, "error": None}
+ except Exception as e:
+ return {"danmaku": [], "error": f"Failed to get or parse danmaku: {str(e)}"}
+
+
+async def get_comments_from_api(aid):
+ all_comments = []
+
+ if not wbiKeys:
+ return {"comments": "", "count": 0, "error": "获取 WBI keys 失败,无法请求评论 API"}
+
+ page_num = 1
+ total_pages = 1
+
+ try:
+ while page_num <= total_pages:
+ params = {
+ "type": 1,
+ "oid": aid,
+ "sort": 2, # sort=2 for hot
+ "pn": page_num,
+ }
+
+ signed_query = encWbi(params, wbiKeys["img_key"], wbiKeys["sub_key"])
+ url = f"{API_GET_COMMENTS}?{signed_query}"
+
+ response = await client.get(url, getHeaders())
+
+ if not response.isSuccessful():
+ console.error(f"Failed to get comments page {page_num}, status: {response.statusCode}")
+ page_num += 1
+ continue
+
+ comments_data = response.json()
+
+ if comments_data.get("code") != 0:
+ console.error(f"API error on page {page_num}: {comments_data.get('message')}")
+ page_num += 1
+ continue
+
+ cdata = comments_data.get("data", {}) if isinstance(comments_data.get("data"), dict) else {}
+ if page_num == 1 and cdata.get("page"):
+ total_pages = math.ceil(cdata["page"]["count"] / cdata["page"]["size"])
+
+ if cdata.get("replies"):
+ for comment in cdata["replies"]:
+ formatted_comment = format_comment(comment)
+ if formatted_comment:
+ all_comments.append(formatted_comment)
+ page_num += 1
+ comment_summary = format_comments_to_string(all_comments)
+ return {"comments": comment_summary, "count": len(all_comments), "error": None}
+ except Exception as e:
+ comment_summary = format_comments_to_string(all_comments)
+ return {"comments": comment_summary, "count": len(all_comments), "error": f"Failed to get comments: {str(e)}"}
+
+
+def format_comment(comment_item):
+ sub_comments = [fc for fc in (format_comment(r) for r in (comment_item.get("replies") or [])) if fc]
+ return {
+ "user": comment_item.get("member", {}).get("uname"),
+ "content": comment_item.get("content", {}).get("message"),
+ "likes": comment_item.get("like") or 0,
+ "time": time.strftime("%Y/%m/%d", time.localtime(comment_item.get("ctime", 0) * 1000 / 1000)),
+ "sub_comments": sub_comments,
+ }
+
+
+def format_comments_to_string(comments):
+ result = []
+
+ def format_single(comment, indent):
+ result.append(f"{indent}- {comment['user']} (👍{comment['likes']}) [{comment['time']}]: {comment['content']}")
+ if comment.get("sub_comments") and len(comment["sub_comments"]) > 0:
+ for sub in comment["sub_comments"]:
+ format_single(sub, indent + " ")
+
+ for comment in comments:
+ format_single(comment, "")
+ return '\n'.join(result)
+
+
+async def get_subtitles(params):
+ await init()
+ bvid = await extract_bvid(params.get("url"))
+ if not bvid:
+ return {"success": False, "message": f"错误: 无法从 URL 提取 BV 号: {params.get('url')}"}
+
+ info = await get_video_basic_info(bvid)
+ aid = info["aid"]
+ cid = info["cid"]
+ infoError = info["error"]
+ if infoError:
+ return {"success": False, "message": f"获取视频信息失败: {infoError}"}
+ if not aid or not cid:
+ return {"success": False, "message": "获取视频 aid 或 cid 失败"}
+
+ sub_result = await get_subtitles_from_api(aid, cid)
+ subtitles = sub_result["subtitles"]
+ subtitlesError = sub_result["error"]
+ if subtitlesError:
+ return {"success": False, "message": f"获取字幕失败: {subtitlesError}"}
+
+ if len(subtitles) == 0:
+ return {"success": True, "message": "该视频没有字幕", "data": []}
+
+ return {"success": True, "message": f"成功获取 {len(subtitles)} 门语言的字幕", "data": subtitles}
+
+
+async def get_danmaku(params):
+ await init()
+ url = params.get("url")
+ count = params.get("count", 500)
+ bvid = await extract_bvid(url)
+ if not bvid:
+ return {"success": False, "message": f"错误: 无法从 URL 提取 BV 号: {url}"}
+
+ if count > 1000:
+ return {"success": False, "message": "参数 'count' 不能超过 1000"}
+
+ info = await get_video_basic_info(bvid)
+ aid = info["aid"]
+ cid = info["cid"]
+ infoError = info["error"]
+ if infoError:
+ return {"success": False, "message": f"获取视频信息失败: {infoError}"}
+ if not cid:
+ return {"success": False, "message": "获取视频 cid 失败"}
+
+ dm_result = await get_danmaku_from_api(cid, count)
+ danmaku = dm_result["danmaku"]
+ danmakuError = dm_result["error"]
+ if danmakuError:
+ return {"success": False, "message": f"获取弹幕失败: {danmakuError}"}
+
+ if len(danmaku) == 0:
+ return {"success": True, "message": "该视频没有弹幕", "data": []}
+
+ return {"success": True, "message": f"成功获取 {len(danmaku)} 条弹幕", "data": danmaku}
+
+
+async def get_comments(params):
+ await init()
+ bvid = await extract_bvid(params.get("url"))
+ if not bvid:
+ return {"success": False, "message": f"错误: 无法从 URL 提取 BV 号: {params.get('url')}"}
+
+ info = await get_video_basic_info(bvid)
+ aid = info["aid"]
+ infoError = info["error"]
+ if infoError:
+ return {"success": False, "message": f"获取视频信息失败: {infoError}"}
+ if not aid:
+ return {"success": False, "message": "获取视频 aid 失败"}
+
+ cmt_result = await get_comments_from_api(aid)
+ comments = cmt_result["comments"]
+ count = cmt_result["count"]
+ commentsError = cmt_result["error"]
+
+ if commentsError and count == 0:
+ return {"success": False, "message": f"获取评论失败: {commentsError}"}
+
+ if count == 0:
+ return {"success": True, "message": "该视频没有热门评论", "data": ""}
+
+ message = (
+ f"成功获取 {count} 条热门评论,但过程中发生错误: {commentsError}"
+ if commentsError
+ else f"成功获取 {count} 条热门评论"
+ )
+
+ return {"success": True, "message": message, "data": comments}
+
+
+async def search_videos_from_api(keyword, page):
+ if not wbiKeys:
+ return {"results": None, "error": "获取 WBI keys 失败,无法请求搜索 API"}
+
+ try:
+ params = {
+ "keyword": keyword,
+ "page": page,
+ "search_type": "video",
+ }
+
+ signed_query = encWbi(params, wbiKeys["img_key"], wbiKeys["sub_key"])
+ url = f"{API_SEARCH}?{signed_query}"
+
+ response = await client.get(url, getHeaders())
+
+ if not response.isSuccessful():
+ return {"results": None, "error": f"搜索失败, status: {response.statusCode}"}
+
+ search_data = response.json()
+ if search_data.get("code") != 0:
+ return {"results": None, "error": f"搜索 API 错误: {search_data.get('message')}"}
+
+ return {"results": search_data.get("data"), "error": None}
+ except Exception as e:
+ return {"results": None, "error": f"搜索时发生异常: {str(e)}"}
+
+
+def format_search_results_to_string(data, count):
+ if not data or not data.get("result"):
+ return {"formatted": "没有找到相关结果。", "result_count": 0, "total_count": 0}
+
+ videoResults = None
+ for item in data.get("result", []):
+ if item.get("result_type") == "video":
+ videoResults = item.get("data")
+ break
+
+ if not videoResults or len(videoResults) == 0:
+ return {"formatted": "没有找到相关视频结果。", "result_count": 0, "total_count": data.get("numResults") or 0}
+
+ slicedResults = videoResults[:count]
+
+ formatted_parts = []
+ for index, video in enumerate(slicedResults):
+ cleanTitle = re.sub(r'|', "", video.get("title", ""))
+ cleanDescription = re.sub(r'|', "", video.get("description", ""))
+ desc_preview = cleanDescription[:100] if cleanDescription else ""
+ desc_preview = desc_preview + ("..." if len(cleanDescription) > 100 else "")
+ parts = [
+ f'{index + 1}. "{cleanTitle}" - {video.get("author")}',
+ f' BV ID: {video.get("bvid")}',
+ f' 播放: {format(video.get("play") or 0, ",")}',
+ f' 弹幕: {format(video.get("danmaku") or 0, ",")}',
+ f' 点赞: {format(video.get("like") or 0, ",")}',
+ f' 时长: {video.get("duration")}',
+ f' 发布于: {time.strftime("%Y/%m/%d", time.localtime(video.get("pubdate", 0)))}',
+ f' 简介: {desc_preview}',
+ ]
+ formatted_parts.append("\n".join(parts))
+
+ formattedResults = "\n\n".join(formatted_parts)
+
+ return {"formatted": formattedResults, "result_count": len(slicedResults), "total_count": data.get("numResults") or 0}
+
+
+async def search_videos(params):
+ await init()
+ keyword = params.get("keyword")
+ page = params.get("page", 1)
+ count = params.get("count", 10)
+
+ if count > 20:
+ return {"success": False, "message": "参数 'count' 不能超过 20"}
+
+ sr = await search_videos_from_api(keyword, page)
+ results = sr["results"]
+ error = sr["error"]
+ if error:
+ return {"success": False, "message": f"搜索失败: {error}"}
+
+ fmt = format_search_results_to_string(results, count)
+ formatted = fmt["formatted"]
+ result_count = fmt["result_count"]
+ total_count = fmt["total_count"]
+
+ if result_count == 0:
+ return {"success": True, "message": "没有找到相关视频。", "data": ""}
+
+ message = f'成功为 "{keyword}" 找到 {total_count} 个相关视频,当前显示第 {page} 页的 {result_count} 个结果。'
+ return {"success": True, "message": message, "data": formatted}
+
+
+def format_user_info_to_string(user):
+ return '\n'.join([
+ f"昵称: {user['name']} (UID: {user['mid']})",
+ f"性别: {user['sex']}",
+ f"等级: LV{user['level']}",
+ f"生日: {user.get('birthday') or '未设置'}",
+ f"粉丝数: {format(user['follower'], ',')}",
+ f"关注数: {format(user['following'], ',')}",
+ f"个人简介: {user.get('sign') or '这个UP主很懒,什么都没有写...'}",
+ f"头像链接: {user['face']}",
+ ])
+
+
+async def get_user_info_from_api(mid):
+ if not wbiKeys:
+ return {"user_info": None, "error": "获取 WBI keys 失败,无法请求用户信息 API"}
+
+ try:
+ # Get user account info (WBI signed)
+ params = {"mid": mid}
+ signed_query = encWbi(params, wbiKeys["img_key"], wbiKeys["sub_key"])
+ url = f"{API_GET_USER_INFO}?{signed_query}"
+ response = await client.get(url, getHeaders())
+
+ if not response.isSuccessful():
+ return {"user_info": None, "error": f"获取用户信息失败, status: {response.statusCode}"}
+
+ userData = response.json()
+ if userData.get("code") != 0:
+ return {"user_info": None, "error": f"用户信息 API 错误: {userData.get('message')}"}
+
+ # Get user relation stat (not WBI signed)
+ statUrl = f"{API_GET_RELATION_STAT}?vmid={mid}"
+ statResponse = await client.get(statUrl, getHeaders())
+ follower = -1
+ following = -1
+ if statResponse.isSuccessful():
+ statData = statResponse.json()
+ if statData.get("code") == 0:
+ follower = statData.get("data", {}).get("follower")
+ following = statData.get("data", {}).get("following")
+
+ udata = userData.get("data", {})
+ userInfo = {
+ "mid": udata.get("mid"),
+ "name": udata.get("name"),
+ "sex": udata.get("sex"),
+ "face": udata.get("face"),
+ "sign": udata.get("sign"),
+ "level": udata.get("level"),
+ "birthday": udata.get("birthday"),
+ "follower": follower,
+ "following": following,
+ }
+
+ return {"user_info": userInfo, "error": None}
+ except Exception as e:
+ return {"user_info": None, "error": f"获取用户信息时发生异常: {str(e)}"}
+
+
+async def get_user_info(params):
+ await init()
+ mid = params.get("mid")
+
+ if not mid or not isinstance(mid, (int, float)) or mid <= 0:
+ return {"success": False, "message": "参数 'mid' 必须是一个正整数"}
+
+ ui = await get_user_info_from_api(mid)
+ user_info = ui["user_info"]
+ error = ui["error"]
+ if error:
+ return {"success": False, "message": f"获取用户信息失败: {error}"}
+
+ if not user_info:
+ return {"success": False, "message": "未能获取到用户信息"}
+
+ formatted_info = format_user_info_to_string(user_info)
+ message = f"成功获取 UID:{mid} 的用户信息。"
+
+ return {"success": True, "message": message, "data": formatted_info}
+
+
+async def wrapToolExecution(func, params):
+ try:
+ result = await func(params)
+ complete(result)
+ except Exception as error:
+ console.error(f"Tool {func.__name__} failed unexpectedly", error)
+ complete({
+ "success": False,
+ "message": f"工具执行时发生意外错误: {str(error)}",
+ })
+
+
+async def main():
+ console.log("--- Bilibili Video Analysis Tool Test ---")
+ testUrl = "https://www.bilibili.com/video/BV1NRE5zmE3W"
+ testKeyword = "OpenAI"
+
+ console.log("\n[1/4] Testing search_videos...")
+ searchResult = await search_videos({"keyword": testKeyword, "count": 10})
+ if searchResult["success"]:
+ console.log(f"Success: {searchResult['message']}")
+ console.log(f'---------- Search Results for "{testKeyword}" ----------')
+ console.log(searchResult["data"])
+ console.log("------------------------------------------")
+ else:
+ console.error(f"Failure: {searchResult['message']}")
+
+ console.log("\n[2/4] Testing get_subtitles...")
+ subtitlesResult = await get_subtitles({"url": testUrl})
+ console.log(json.dumps(subtitlesResult, indent=2, ensure_ascii=False))
+
+ console.log("\n[3/4] Testing get_danmaku with count limit...")
+ danmakuResult = await get_danmaku({"url": testUrl, "count": 20})
+ console.log(json.dumps(danmakuResult, indent=2, ensure_ascii=False))
+
+ console.log("\n[4/4] Testing get_comments...")
+ commentsResult = await get_comments({"url": testUrl})
+ if commentsResult["success"]:
+ console.log(f"Success: {commentsResult['message']}")
+ console.log("---------- Comments ----------")
+ console.log(commentsResult["data"])
+ console.log("----------------------------")
+ else:
+ console.error(f"Failure: {commentsResult['message']}")
+
+ console.log("\n[5/5] Testing get_user_info...")
+ userInfoResult = await get_user_info({"mid": 2})
+ if userInfoResult["success"]:
+ console.log(f"Success: {userInfoResult['message']}")
+ console.log("---------- User Info ----------")
+ console.log(userInfoResult["data"])
+ console.log("-----------------------------")
+ else:
+ console.error(f"Failure: {userInfoResult['message']}")
+
+ complete({"success": True, "message": "Test finished."})
+
+
+async def _get_subtitles_wrapper(params):
+ await wrapToolExecution(get_subtitles, params)
+
+
+async def _get_danmaku_wrapper(params):
+ await wrapToolExecution(get_danmaku, params)
+
+
+async def _get_comments_wrapper(params):
+ await wrapToolExecution(get_comments, params)
+
+
+async def _search_videos_wrapper(params):
+ await wrapToolExecution(search_videos, params)
+
+
+async def _get_user_info_wrapper(params):
+ await wrapToolExecution(get_user_info, params)
+
+
+exports.get_subtitles = _get_subtitles_wrapper
+exports.get_danmaku = _get_danmaku_wrapper
+exports.get_comments = _get_comments_wrapper
+exports.search_videos = _search_videos_wrapper
+exports.get_user_info = _get_user_info_wrapper
+exports.main = main
diff --git a/operit_py/browser.py b/operit_py/browser.py
new file mode 100644
index 0000000..bb8d56c
--- /dev/null
+++ b/operit_py/browser.py
@@ -0,0 +1,410 @@
+# METADATA
+# {
+# "name": "browser",
+# "display_name": {
+# "zh": "Browser 自动化操作",
+# "en": "Browser Automation"
+# },
+# "description": {
+# "zh": "严格对齐 Playwright MCP 默认 browser 工具面的浏览器自动化工具集。",
+# "en": "Browser automation tools aligned to the default Playwright MCP browser surface."
+# },
+# "enabledByDefault": true,
+# "category": "Automatic",
+# "tools": [
+# {
+# "name": "click",
+# "description": { "zh": "按快照 ref 点击页面元素,包括同源 iframe 内的元素。", "en": "Click an element by snapshot ref, including refs inside same-origin iframes." },
+# "parameters": [
+# { "name": "ref", "description": { "zh": "快照中的目标元素引用;和 selector 至少提供一个。", "en": "Target element ref from the snapshot; provide ref or selector." }, "type": "string", "required": false },
+# { "name": "selector", "description": { "zh": "可选,ref 不可用时的元素选择器。", "en": "Optional selector fallback when ref is not available." }, "type": "string", "required": false },
+# { "name": "element", "description": { "zh": "可选,人类可读元素描述。", "en": "Optional human-readable element description." }, "type": "string", "required": false },
+# { "name": "doubleClick", "description": { "zh": "可选,是否双击。", "en": "Optional double click." }, "type": "boolean", "required": false },
+# { "name": "button", "description": { "zh": "可选,left/right/middle。", "en": "Optional mouse button: left/right/middle." }, "type": "string", "required": false },
+# { "name": "modifiers", "description": { "zh": "可选,修饰键数组。", "en": "Optional modifier keys array." }, "type": "array", "required": false }
+# ]
+# },
+# {
+# "name": "close",
+# "description": { "zh": "关闭当前 tab。", "en": "Close the current tab." },
+# "parameters": []
+# },
+# {
+# "name": "console_messages",
+# "description": { "zh": "读取控制台消息。", "en": "Read console messages." },
+# "parameters": [
+# { "name": "level", "description": { "zh": "可选,日志级别:error/warning/info/debug,默认 info。", "en": "Optional log level: error/warning/info/debug. Defaults to info." }, "type": "string", "required": false },
+# { "name": "filename", "description": { "zh": "可选,保存输出的文件名。", "en": "Optional output file name." }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "drag",
+# "description": { "zh": "在两个元素之间拖拽。", "en": "Drag between two elements." },
+# "parameters": [
+# { "name": "startElement", "description": { "zh": "源元素的人类可读描述。", "en": "Human-readable source element description." }, "type": "string", "required": true },
+# { "name": "startRef", "description": { "zh": "源元素 ref。", "en": "Source element ref." }, "type": "string", "required": true },
+# { "name": "endElement", "description": { "zh": "目标元素的人类可读描述。", "en": "Human-readable target element description." }, "type": "string", "required": true },
+# { "name": "endRef", "description": { "zh": "目标元素 ref。", "en": "Target element ref." }, "type": "string", "required": true }
+# ]
+# },
+# {
+# "name": "evaluate",
+# "description": { "zh": "在页面或元素上执行 JavaScript 函数。", "en": "Evaluate a JavaScript function on the page or an element." },
+# "parameters": [
+# { "name": "function", "description": { "zh": "要执行的函数源码。", "en": "Function source to execute." }, "type": "string", "required": true },
+# { "name": "element", "description": { "zh": "可选,人类可读元素描述。", "en": "Optional human-readable element description." }, "type": "string", "required": false },
+# { "name": "ref", "description": { "zh": "可选,目标元素 ref。", "en": "Optional target element ref." }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "upload",
+# "description": { "zh": "向当前文件选择器上传文件。", "en": "Upload files to the current file chooser." },
+# "parameters": [
+# { "name": "paths", "description": { "zh": "可选,绝对路径数组;不传则取消 file chooser。", "en": "Optional absolute file paths; omit to cancel the file chooser." }, "type": "array", "required": false }
+# ]
+# },
+# {
+# "name": "fill_form",
+# "description": { "zh": "批量填写表单字段。", "en": "Fill multiple form fields." },
+# "parameters": [
+# { "name": "fields", "description": { "zh": "字段数组。", "en": "Array of form fields." }, "type": "array", "required": true }
+# ]
+# },
+# {
+# "name": "handle_dialog",
+# "description": { "zh": "处理当前对话框。", "en": "Handle the current dialog." },
+# "parameters": [
+# { "name": "accept", "description": { "zh": "是否接受对话框。", "en": "Whether to accept the dialog." }, "type": "boolean", "required": true },
+# { "name": "promptText", "description": { "zh": "可选,prompt 的输入文本。", "en": "Optional prompt text." }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "hover",
+# "description": { "zh": "悬停到页面元素上。", "en": "Hover over an element." },
+# "parameters": [
+# { "name": "ref", "description": { "zh": "目标元素 ref。", "en": "Target element ref." }, "type": "string", "required": true },
+# { "name": "element", "description": { "zh": "可选,人类可读元素描述。", "en": "Optional human-readable element description." }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "goto",
+# "description": { "zh": "导航到指定 URL。", "en": "Navigate to a URL." },
+# "parameters": [
+# { "name": "url", "description": { "zh": "目标 URL。", "en": "Target URL." }, "type": "string", "required": true }
+# ]
+# },
+# {
+# "name": "back",
+# "description": { "zh": "后退到上一页。", "en": "Go back to the previous page." },
+# "parameters": []
+# },
+# {
+# "name": "network_requests",
+# "description": { "zh": "读取当前页面的网络请求。", "en": "Read network requests for the current page." },
+# "parameters": [
+# { "name": "includeStatic", "description": { "zh": "可选,是否包含静态资源请求,默认 false。", "en": "Optional include static resource requests. Defaults to false." }, "type": "boolean", "required": false },
+# { "name": "filename", "description": { "zh": "可选,保存输出的文件名。", "en": "Optional output file name." }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "press_key",
+# "description": { "zh": "按下键盘按键。", "en": "Press a keyboard key." },
+# "parameters": [
+# { "name": "key", "description": { "zh": "按键名。", "en": "Key name." }, "type": "string", "required": true }
+# ]
+# },
+# {
+# "name": "resize",
+# "description": { "zh": "调整浏览器视口大小。", "en": "Resize the browser viewport." },
+# "parameters": [
+# { "name": "width", "description": { "zh": "宽度。", "en": "Width." }, "type": "number", "required": true },
+# { "name": "height", "description": { "zh": "高度。", "en": "Height." }, "type": "number", "required": true }
+# ]
+# },
+# {
+# "name": "run_code",
+# "description": { "zh": "运行 Playwright 风格代码片段。", "en": "Run a Playwright-style code snippet." },
+# "parameters": [
+# { "name": "code", "description": { "zh": "代码片段。", "en": "Code snippet." }, "type": "string", "required": true }
+# ]
+# },
+# {
+# "name": "select_option",
+# "description": { "zh": "在下拉框中选择选项。", "en": "Select options in a dropdown." },
+# "parameters": [
+# { "name": "ref", "description": { "zh": "目标元素 ref。", "en": "Target element ref." }, "type": "string", "required": true },
+# { "name": "values", "description": { "zh": "要选择的值数组。", "en": "Values to select." }, "type": "array", "required": true },
+# { "name": "element", "description": { "zh": "可选,人类可读元素描述。", "en": "Optional human-readable element description." }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "snapshot",
+# "description": { "zh": "获取当前页面结构化快照,包括同源 iframe 内容。", "en": "Get a structured page snapshot, including same-origin iframe content." },
+# "parameters": [
+# { "name": "filename", "description": { "zh": "可选,保存快照的文件名。", "en": "Optional snapshot output file name." }, "type": "string", "required": false },
+# { "name": "selector", "description": { "zh": "可选,作为快照根节点的元素选择器。", "en": "Optional root element selector for a partial snapshot." }, "type": "string", "required": false },
+# { "name": "depth", "description": { "zh": "可选,限制快照树深度。", "en": "Optional snapshot tree depth limit." }, "type": "number", "required": false }
+# ]
+# },
+# {
+# "name": "type",
+# "description": { "zh": "向可编辑元素输入文本。", "en": "Type text into an editable element." },
+# "parameters": [
+# { "name": "ref", "description": { "zh": "目标元素 ref。", "en": "Target element ref." }, "type": "string", "required": true },
+# { "name": "text", "description": { "zh": "输入文本。", "en": "Text to type." }, "type": "string", "required": true },
+# { "name": "element", "description": { "zh": "可选,人类可读元素描述。", "en": "Optional human-readable element description." }, "type": "string", "required": false },
+# { "name": "submit", "description": { "zh": "可选,输入后是否提交。", "en": "Optional submit after typing." }, "type": "boolean", "required": false },
+# { "name": "slowly", "description": { "zh": "可选,是否逐字输入。", "en": "Optional type slowly." }, "type": "boolean", "required": false }
+# ]
+# },
+# {
+# "name": "wait_for",
+# "description": { "zh": "等待文本出现、消失或等待指定时间。", "en": "Wait for text to appear, disappear, or for a duration." },
+# "parameters": [
+# { "name": "time", "description": { "zh": "可选,等待秒数。", "en": "Optional number of seconds to wait." }, "type": "number", "required": false },
+# { "name": "text", "description": { "zh": "可选,等待出现的文本。", "en": "Optional text to wait for." }, "type": "string", "required": false },
+# { "name": "textGone", "description": { "zh": "可选,等待消失的文本。", "en": "Optional text to wait to disappear." }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "tabs",
+# "description": { "zh": "列出、创建、切换或关闭 tab。", "en": "List, create, select, or close tabs." },
+# "parameters": [
+# { "name": "action", "description": { "zh": "操作:list/create/select/close。", "en": "Action: list/create/select/close." }, "type": "string", "required": true },
+# { "name": "index", "description": { "zh": "可选,0-based tab 索引。", "en": "Optional 0-based tab index." }, "type": "number", "required": false }
+# ]
+# }
+# ]
+# }
+
+import datetime
+import random
+import re
+
+MAX_INLINE_BROWSER_TEXT_CHARS = 24000
+
+TOOL_NAMES = [
+ "click",
+ "close",
+ "close_all",
+ "console_messages",
+ "drag",
+ "evaluate",
+ "upload",
+ "fill_form",
+ "handle_dialog",
+ "hover",
+ "goto",
+ "back",
+ "network_requests",
+ "press_key",
+ "resize",
+ "run_code",
+ "select_option",
+ "snapshot",
+ "type",
+ "wait_for",
+ "tabs"
+]
+
+
+def normalizeOptionalString(value=None):
+ if value is None:
+ return None
+ normalized = str(value).strip()
+ return normalized if normalized else None
+
+
+def buildLargeOutputFilename(prefix, extension):
+ timestamp = datetime.datetime.utcnow().isoformat().replace(":", "-").replace(".", "-")
+ rand = int(random.random() * 1000000)
+ return OPERIT_CLEAN_ON_EXIT_DIR + "/browser_" + prefix + "_" + timestamp + "_" + rand + "." + extension
+
+
+async def maybePersistLargeBrowserResponse(result, prefix, extension="md"):
+ if not isinstance(result, str) or len(result) <= MAX_INLINE_BROWSER_TEXT_CHARS:
+ return result
+ await Tools.Files.mkdir(OPERIT_CLEAN_ON_EXIT_DIR, True)
+ filename = buildLargeOutputFilename(prefix, extension)
+ await Tools.Files.write(filename, result, False)
+ normalizedPath = filename.replace("\\", "/")
+ return "Large browser response saved to:\n- [Browser Output](" + normalizedPath + ")"
+
+
+async def click(params):
+ result = await Tools.Net.browserClick(params)
+ return await maybePersistLargeBrowserResponse(result, "click")
+
+
+async def close(params=None):
+ if params is None:
+ params = {}
+ result = await Tools.Net.browserClose()
+ return await maybePersistLargeBrowserResponse(result, "close")
+
+
+async def close_all(params=None):
+ if params is None:
+ params = {}
+ result = await Tools.Net.browserCloseAll()
+ return await maybePersistLargeBrowserResponse(result, "close_all")
+
+
+async def console_messages(params=None):
+ if params is None:
+ params = {}
+ result = await Tools.Net.browserConsoleMessages(params)
+ return await maybePersistLargeBrowserResponse(result, "console_messages")
+
+
+async def drag(params):
+ result = await Tools.Net.browserDrag(params)
+ return await maybePersistLargeBrowserResponse(result, "drag")
+
+
+async def evaluate(params):
+ result = await Tools.Net.browserEvaluate(params)
+ return await maybePersistLargeBrowserResponse(result, "evaluate")
+
+
+async def upload(params=None):
+ if params is None:
+ params = {}
+ result = await Tools.Net.browserFileUpload(params)
+ return await maybePersistLargeBrowserResponse(result, "upload")
+
+
+def normalizeFormFields(fields):
+ if len(fields) == 0:
+ raise Exception("fields must be a non-empty array")
+ normalizedFields = []
+ for index, field in enumerate(fields):
+ normalized = {
+ "name": str(field.get("name", "")).strip(),
+ "type": str(field.get("type", "")).strip(),
+ "value": field.get("value")
+ }
+ if not normalized["name"]:
+ raise Exception("fields[" + str(index) + "].name is required")
+ if not normalized["type"]:
+ raise Exception("fields[" + str(index) + "].type is required")
+ ref = normalizeOptionalString(field.get("ref"))
+ selector = normalizeOptionalString(field.get("selector"))
+ if not ref and not selector:
+ raise Exception("fields[" + str(index) + "] requires ref or selector")
+ if ref:
+ normalized["ref"] = ref
+ if selector:
+ normalized["selector"] = selector
+ normalizedFields.append(normalized)
+ return normalizedFields
+
+
+async def fill_form(params):
+ result = await Tools.Net.browserFillForm({
+ "fields": normalizeFormFields(params["fields"])
+ })
+ return await maybePersistLargeBrowserResponse(result, "fill_form")
+
+
+async def handle_dialog(params):
+ result = await Tools.Net.browserHandleDialog(params)
+ return await maybePersistLargeBrowserResponse(result, "handle_dialog")
+
+
+async def hover(params):
+ result = await Tools.Net.browserHover(params)
+ return await maybePersistLargeBrowserResponse(result, "hover")
+
+
+async def goto(params):
+ result = await Tools.Net.browserNavigate(params)
+ return await maybePersistLargeBrowserResponse(result, "goto")
+
+
+async def back(params=None):
+ if params is None:
+ params = {}
+ result = await Tools.Net.browserNavigateBack()
+ return await maybePersistLargeBrowserResponse(result, "back")
+
+
+async def network_requests(params=None):
+ if params is None:
+ params = {}
+ result = await Tools.Net.browserNetworkRequests(params)
+ return await maybePersistLargeBrowserResponse(result, "network_requests")
+
+
+async def press_key(params):
+ result = await Tools.Net.browserPressKey(params)
+ return await maybePersistLargeBrowserResponse(result, "press_key")
+
+
+async def resize(params):
+ result = await Tools.Net.browserResize(params)
+ return await maybePersistLargeBrowserResponse(result, "resize")
+
+
+async def run_code(params):
+ result = await Tools.Net.browserRunCode(params)
+ return await maybePersistLargeBrowserResponse(result, "run_code")
+
+
+async def select_option(params):
+ result = await Tools.Net.browserSelectOption(params)
+ return await maybePersistLargeBrowserResponse(result, "select_option")
+
+
+async def snapshot(params=None):
+ if params is None:
+ params = {}
+ result = await Tools.Net.browserSnapshot(params)
+ return await maybePersistLargeBrowserResponse(result, "snapshot")
+
+
+async def type(params):
+ result = await Tools.Net.browserType(params)
+ return await maybePersistLargeBrowserResponse(result, "type")
+
+
+async def wait_for(params=None):
+ if params is None:
+ params = {}
+ result = await Tools.Net.browserWaitFor(params)
+ return await maybePersistLargeBrowserResponse(result, "wait_for")
+
+
+async def tabs(params):
+ result = await Tools.Net.browserTabs(params)
+ return await maybePersistLargeBrowserResponse(result, "tabs")
+
+
+async def browserMain(params=None):
+ if params is None:
+ params = {}
+ return "Browser package ready: " + ", ".join(TOOL_NAMES)
+
+
+exports.click = click
+exports.close = close
+exports.close_all = close_all
+exports.console_messages = console_messages
+exports.drag = drag
+exports.evaluate = evaluate
+exports.upload = upload
+exports.fill_form = fill_form
+exports.handle_dialog = handle_dialog
+exports.hover = hover
+exports.goto = goto
+exports.back = back
+exports.network_requests = network_requests
+exports.press_key = press_key
+exports.resize = resize
+exports.run_code = run_code
+exports.select_option = select_option
+exports.snapshot = snapshot
+exports.type = type
+exports.wait_for = wait_for
+exports.tabs = tabs
+exports.main = browserMain
diff --git a/operit_py/code_runner.py b/operit_py/code_runner.py
new file mode 100644
index 0000000..5948cb2
--- /dev/null
+++ b/operit_py/code_runner.py
@@ -0,0 +1,780 @@
+# METADATA
+# {
+# "name": "code_runner",
+# "display_name": {"zh": "代码运行器", "en": "Code Runner"},
+# "description": {"zh": "提供多语言代码执行能力,支持JavaScript、Python、Ruby、Go、Rust、C和C++脚本的运行。可直接执行代码字符串或运行外部文件,适用于快速测试、自动化脚本和教学演示。", "en": "Multi-language code execution. Supports running JavaScript, Python, Ruby, Go, Rust, C and C++ scripts. You can execute code strings directly or run external files, useful for quick tests, automation, and demos."},
+# "enabledByDefault": True,
+# "category": "Development",
+# "tools": [
+# {"name": "run_javascript_es5", "description": {"zh": "运行自定义 JavaScript (ES5) 脚本。", "en": "Run custom JavaScript (ES5)."}, "parameters": [{"name": "script", "description": {"zh": "要执行的 JavaScript 脚本内容", "en": "JavaScript script content to execute."}, "type": "string", "required": True}]},
+# {"name": "run_javascript_file", "description": {"zh": "运行 JavaScript (ES5) 文件。", "en": "Run a JavaScript (ES5) file."}, "parameters": [{"name": "file_path", "description": {"zh": "JavaScript 文件路径", "en": "Path to the JavaScript file."}, "type": "string", "required": True}]},
+# {"name": "run_javascript_node", "description": {"zh": "使用 Node.js 运行 JavaScript 脚本。", "en": "Run JavaScript using Node.js."}, "parameters": [{"name": "script", "description": {"zh": "要执行的 JavaScript 脚本内容", "en": "JavaScript script content to execute."}, "type": "string", "required": True}, {"name": "node_flags", "description": {"zh": "Node.js 解释器选项", "en": "Node.js interpreter flags."}, "type": "string", "required": False}]},
+# {"name": "run_javascript_node_file", "description": {"zh": "使用 Node.js 运行 JavaScript 文件。", "en": "Run a JavaScript file using Node.js."}, "parameters": [{"name": "file_path", "description": {"zh": "JavaScript 文件路径", "en": "Path to the JavaScript file."}, "type": "string", "required": True}, {"name": "node_flags", "description": {"zh": "Node.js 解释器选项", "en": "Node.js interpreter flags."}, "type": "string", "required": False}]},
+# {"name": "install_node_packages", "description": {"zh": "在持久 Node 工作目录中安装 pnpm 包", "en": "Install packages with pnpm in the persistent Node workspace."}, "parameters": [{"name": "packages", "description": {"zh": "要安装的包名(用 | 分隔)", "en": "Package names to install, separated by |."}, "type": "string", "required": True}, {"name": "save_dev", "description": {"zh": "是否作为开发依赖安装", "en": "Whether to install as dev dependency."}, "type": "boolean", "required": False}]},
+# {"name": "install_python_packages", "description": {"zh": "在持久虚拟环境中安装 Python 包", "en": "Install Python packages in the persistent virtual environment using pip."}, "parameters": [{"name": "packages", "description": {"zh": "要安装的包名(用 | 分隔)", "en": "Package names to install, separated by |."}, "type": "string", "required": True}, {"name": "upgrade", "description": {"zh": "是否升级已安装的包", "en": "Whether to upgrade already installed packages."}, "type": "boolean", "required": False}]},
+# {"name": "run_python", "description": {"zh": "运行自定义 Python 脚本。", "en": "Run custom Python scripts."}, "parameters": [{"name": "script", "description": {"zh": "要执行的 Python 脚本内容", "en": "Python script content to execute."}, "type": "string", "required": True}, {"name": "python_flags", "description": {"zh": "Python 解释器选项", "en": "Python interpreter flags."}, "type": "string", "required": False}, {"name": "script_args", "description": {"zh": "传递给 Python 脚本的参数", "en": "Arguments passed to the Python script."}, "type": "string", "required": False}]},
+# {"name": "run_python_file", "description": {"zh": "运行 Python 文件。", "en": "Run a Python file."}, "parameters": [{"name": "file_path", "description": {"zh": "Python 文件路径", "en": "Path to the Python file."}, "type": "string", "required": True}, {"name": "python_flags", "description": {"zh": "Python 解释器选项", "en": "Python interpreter flags."}, "type": "string", "required": False}, {"name": "script_args", "description": {"zh": "传递给 Python 文件的参数", "en": "Arguments passed to the Python file."}, "type": "string", "required": False}]},
+# {"name": "run_ruby", "description": {"zh": "运行自定义 Ruby 脚本", "en": "Run custom Ruby scripts."}, "parameters": [{"name": "script", "description": {"zh": "要执行的 Ruby 脚本内容", "en": "Ruby script content to execute."}, "type": "string", "required": True}, {"name": "ruby_flags", "description": {"zh": "Ruby 解释器选项", "en": "Ruby interpreter flags."}, "type": "string", "required": False}]},
+# {"name": "run_ruby_file", "description": {"zh": "运行 Ruby 文件", "en": "Run a Ruby file."}, "parameters": [{"name": "file_path", "description": {"zh": "Ruby 文件路径", "en": "Path to the Ruby file."}, "type": "string", "required": True}, {"name": "ruby_flags", "description": {"zh": "Ruby 解释器选项", "en": "Ruby interpreter flags."}, "type": "string", "required": False}]},
+# {"name": "run_go", "description": {"zh": "运行自定义 Go 代码", "en": "Run custom Go code."}, "parameters": [{"name": "script", "description": {"zh": "要执行的 Go 代码内容", "en": "Go source code to execute."}, "type": "string", "required": True}, {"name": "build_flags", "description": {"zh": "Go 编译选项", "en": "Go build flags."}, "type": "string", "required": False}]},
+# {"name": "run_go_file", "description": {"zh": "运行 Go 文件", "en": "Run a Go file."}, "parameters": [{"name": "file_path", "description": {"zh": "Go 文件路径", "en": "Path to the Go file."}, "type": "string", "required": True}, {"name": "build_flags", "description": {"zh": "Go 编译选项", "en": "Go build flags."}, "type": "string", "required": False}]},
+# {"name": "run_rust", "description": {"zh": "运行自定义 Rust 代码", "en": "Run custom Rust code."}, "parameters": [{"name": "script", "description": {"zh": "要执行的 Rust 代码内容", "en": "Rust source code to execute."}, "type": "string", "required": True}, {"name": "cargo_flags", "description": {"zh": "Cargo 构建选项", "en": "Cargo build flags."}, "type": "string", "required": False}]},
+# {"name": "run_rust_file", "description": {"zh": "运行 Rust 文件", "en": "Run a Rust file."}, "parameters": [{"name": "file_path", "description": {"zh": "Rust 文件路径", "en": "Path to the Rust file."}, "type": "string", "required": True}, {"name": "cargo_flags", "description": {"zh": "Cargo 构建选项", "en": "Cargo build flags."}, "type": "string", "required": False}]},
+# {"name": "run_c", "description": {"zh": "运行自定义 C 代码", "en": "Run custom C code."}, "parameters": [{"name": "script", "description": {"zh": "要执行的 C 代码内容", "en": "C source code to execute."}, "type": "string", "required": True}, {"name": "compile_flags", "description": {"zh": "编译选项", "en": "Compile flags."}, "type": "string", "required": False}]},
+# {"name": "run_c_file", "description": {"zh": "运行 C 文件", "en": "Run a C file."}, "parameters": [{"name": "file_path", "description": {"zh": "C 文件路径", "en": "Path to the C file."}, "type": "string", "required": True}, {"name": "compile_flags", "description": {"zh": "编译选项", "en": "Compile flags."}, "type": "string", "required": False}]},
+# {"name": "run_cpp", "description": {"zh": "运行自定义 C++ 代码", "en": "Run custom C++ code."}, "parameters": [{"name": "script", "description": {"zh": "要执行的 C++ 代码内容", "en": "C++ source code to execute."}, "type": "string", "required": True}, {"name": "compile_flags", "description": {"zh": "编译选项", "en": "Compile flags."}, "type": "string", "required": False}]},
+# {"name": "run_cpp_file", "description": {"zh": "运行 C++ 文件", "en": "Run a C++ file."}, "parameters": [{"name": "file_path", "description": {"zh": "C++ 文件路径", "en": "Path to the C++ file."}, "type": "string", "required": True}, {"name": "compile_flags", "description": {"zh": "编译选项", "en": "Compile flags."}, "type": "string", "required": False}]}
+# ]
+# }
+
+import json
+import re
+import time
+from urllib.parse import quote, unquote
+
+Error = Exception
+
+CARGO_MIRROR_ENV = 'export CARGO_REGISTRIES_CRATES_IO_REPLACE_WITH="ustc" && export CARGO_REGISTRIES_USTC_INDEX="https://mirrors.ustc.edu.cn/crates.io-index"'
+CODE_RUNNER_HIDDEN_EXECUTOR_KEY = "code_runner_hidden_executor"
+NODE_WORKSPACE_DIR = "$HOME/.code_runner/node"
+
+
+async def executeTerminalCommand(command, timeoutMs=None):
+ options = {"executorKey": CODE_RUNNER_HIDDEN_EXECUTOR_KEY}
+ if timeoutMs is not None:
+ options["timeoutMs"] = timeoutMs
+ return await Tools.System.terminal.hiddenExec(command, options)
+
+
+async def ensurePersistentVenv():
+ venvDir = "~/.code_runner/py"
+ pythonBin = f"{venvDir}/bin/python"
+ pipBin = f"{venvDir}/bin/pip"
+
+ exists = await executeTerminalCommand(f"[ -x {pythonBin} ] && echo OK || echo NO")
+ if "OK" in (getattr(exists, "output", "") or ""):
+ return {"pythonBin": pythonBin, "pipBin": pipBin}
+
+ setup = await executeTerminalCommand(f"python3 -m venv {venvDir}")
+ if getattr(setup, "exitCode", 1) != 0 or hasError(getattr(setup, "output", "") or ""):
+ raise Error(f"创建持久 venv 失败:\n{getattr(setup, 'output', '')}")
+ return {"pythonBin": pythonBin, "pipBin": pipBin}
+
+
+async def install_python_packages(params):
+ raw = (params.get("packages") or "").strip()
+ pkgs = [s.strip() for s in raw.split("|") if s.strip()]
+ if not pkgs:
+ raise Error("请提供要安装的包列表(用 | 分隔)packages")
+
+ upgradeFlag = "-U" if params.get("upgrade") else ""
+ venv = await ensurePersistentVenv()
+ pythonBin = venv["pythonBin"]
+
+ r2 = await executeTerminalCommand(f"{pythonBin} -m pip install {upgradeFlag} {' '.join(pkgs)}".strip())
+ if getattr(r2, "exitCode", 1) != 0 or hasError(getattr(r2, "output", "") or ""):
+ raise Error(f"安装依赖失败:\n{getattr(r2, 'output', '')}")
+ return f"Installed with pip:\n{getattr(r2, 'output', '')}".strip()
+
+
+async def ensureNodeAvailable():
+ nodeCheckResult = await executeTerminalCommand("node --version")
+ if getattr(nodeCheckResult, "exitCode", 1) != 0 or hasError(getattr(nodeCheckResult, "output", "") or ""):
+ raise Error("Node.js 不可用,请确保已安装 Node.js")
+
+
+async def ensurePersistentNodeWorkspace():
+ await ensureNodeAvailable()
+
+ createDirResult = await executeTerminalCommand(f"mkdir -p {NODE_WORKSPACE_DIR}")
+ if getattr(createDirResult, "exitCode", 1) != 0 or hasError(getattr(createDirResult, "output", "") or ""):
+ raise Error(f"创建 Node 工作目录失败:\n{getattr(createDirResult, 'output', '')}")
+
+ hasPackageJson = await executeTerminalCommand(f"[ -f {NODE_WORKSPACE_DIR}/package.json ] && echo OK || echo NO")
+ if "OK" not in (getattr(hasPackageJson, "output", "") or ""):
+ initResult = await executeTerminalCommand(
+ f"cat <<'EOF' > {NODE_WORKSPACE_DIR}/package.json\n{{\n \"name\": \"code-runner-node-workspace\",\n \"private\": true\n}}\nEOF"
+ )
+ if getattr(initResult, "exitCode", 1) != 0 or hasError(getattr(initResult, "output", "") or ""):
+ raise Error(f"初始化 Node 工作目录失败:\n{getattr(initResult, 'output', '')}")
+
+ return {"workspaceDir": NODE_WORKSPACE_DIR}
+
+
+async def install_node_packages(params):
+ raw = (params.get("packages") or "").strip()
+ pkgs = [s.strip() for s in raw.split("|") if s.strip()]
+ if not pkgs:
+ raise Error("请提供要安装的包列表(用 | 分隔)packages")
+
+ ws = await ensurePersistentNodeWorkspace()
+ workspaceDir = ws["workspaceDir"]
+ saveFlag = "-D" if params.get("save_dev") else "--save"
+ packageArgs = " ".join([f"'{escapeForShell(p)}'" for p in pkgs])
+ result = await executeTerminalCommand(f"cd {workspaceDir} && pnpm add {saveFlag} {packageArgs}")
+ if getattr(result, "exitCode", 1) != 0 or hasError(getattr(result, "output", "") or ""):
+ raise Error(f"安装 pnpm 依赖失败:\n{getattr(result, 'output', '')}")
+ return f"Installed with pnpm in {workspaceDir}:\n{getattr(result, 'output', '')}".strip()
+
+
+def escapeForShell(s):
+ return str(s).replace("'", "'\\''")
+
+
+def buildPipeSeparatedShellArgs(raw):
+ if not raw or raw.strip() == "":
+ return ""
+ parts = [p.strip() for p in raw.split("|") if p.strip()]
+ return " ".join([f"'{escapeForShell(p)}'" for p in parts])
+
+
+async def executeJavaScript(script):
+ # 注意:原 TypeScript 使用 new Function() 在内置 JS 引擎中执行。
+ # Python 运行时无内置 JS 引擎,此处降级为通过 Node.js 执行。
+ ws = await ensurePersistentNodeWorkspace()
+ workspaceDir = ws["workspaceDir"]
+ tempFileName = f"temp_script_es5_{int(time.time() * 1000)}.js"
+ tempFilePath = f"{workspaceDir}/{tempFileName}"
+ try:
+ await executeTerminalCommand(f"cat <<'EOF' > {tempFilePath}\n{script}\nEOF")
+ result = await executeTerminalCommand(f"cd {workspaceDir} && NODE_PATH={workspaceDir}/node_modules node {tempFileName}")
+ if getattr(result, "exitCode", 1) == 0 and not hasError(getattr(result, "output", "") or ""):
+ return (getattr(result, "output", "") or "").strip()
+ else:
+ raise Error(f"JavaScript (ES5) 脚本执行失败:\n{getattr(result, 'output', '')}")
+ finally:
+ try:
+ await executeTerminalCommand(f"rm -f {tempFilePath}")
+ except Exception as err:
+ console.error(f"删除临时文件失败: {str(err)}")
+
+
+def hasError(output):
+ errorPatterns = [
+ "command not found", "No such file or directory",
+ "error:", "Error:", "failed", "Failed", "unable", "Unable",
+ ]
+ lowercasedOutput = (output or "").lower()
+ return any(pattern.lower() in lowercasedOutput for pattern in errorPatterns)
+
+
+async def main():
+ await executeTerminalCommand("mkdir -p /tmp")
+
+ results = {
+ "javascript": await testJavaScript(),
+ "python": await testPython(),
+ "ruby": await testRuby(),
+ "go": await testGo(),
+ "rust": await testRust(),
+ "c": await testC(),
+ "cpp": await testCpp(),
+ }
+
+ summary = "代码执行器功能测试结果:\n"
+ for lang, result in results.items():
+ summary += f"{lang}: {'✅ 成功' if result.get('success') else '❌ 失败'} - {result.get('message')}\n"
+
+ return summary
+
+
+async def testJavaScript():
+ try:
+ script = "console.log('JavaScript 运行正常'); const testVar = 42; console.log('测试值: ' + testVar);"
+ result = await executeJavaScript(script)
+ if "JavaScript 运行正常" in result:
+ return {"success": True, "message": "JavaScript执行器测试成功"}
+ return {"success": False, "message": f"JavaScript执行器测试失败: 实际 \"{result}\""}
+ except Exception as error:
+ return {"success": False, "message": f"JavaScript执行器测试失败: {str(error)}"}
+
+
+async def testPython():
+ try:
+ pythonCheckResult = await executeTerminalCommand("python3 --version")
+ if getattr(pythonCheckResult, "exitCode", 1) != 0 or hasError(getattr(pythonCheckResult, "output", "") or ""):
+ return {"success": False, "message": "Python不可用,请确保已安装Python"}
+
+ script = "print('Python运行正常')"
+ tempPyFile = "/tmp/test_python.py"
+ await executeTerminalCommand(f"cat <<'EOF' > {tempPyFile}\n{script}\nEOF")
+ runResult = await executeTerminalCommand(f"python3 {tempPyFile}")
+ await executeTerminalCommand(f"rm -f {tempPyFile}")
+
+ if getattr(runResult, "exitCode", 1) != 0 or hasError(getattr(runResult, "output", "") or "") or "Python运行正常" not in (getattr(runResult, "output", "") or ""):
+ return {"success": False, "message": f"Python执行器测试失败: {getattr(runResult, 'output', '')}"}
+ return {"success": True, "message": "Python执行器测试成功"}
+ except Exception as error:
+ return {"success": False, "message": f"Python执行器测试失败: {str(error)}"}
+
+
+async def testRuby():
+ try:
+ rubyCheckResult = await executeTerminalCommand("ruby --version")
+ if getattr(rubyCheckResult, "exitCode", 1) != 0 or hasError(getattr(rubyCheckResult, "output", "") or ""):
+ return {"success": False, "message": "Ruby不可用,请确保已安装Ruby"}
+
+ script = "puts 'Ruby运行正常'"
+ tempRbFile = "/tmp/test_ruby.rb"
+ await executeTerminalCommand(f"cat <<'EOF' > {tempRbFile}\n{script}\nEOF")
+ runResult = await executeTerminalCommand(f"ruby {tempRbFile}")
+ await executeTerminalCommand(f"rm -f {tempRbFile}")
+
+ if getattr(runResult, "exitCode", 1) != 0 or hasError(getattr(runResult, "output", "") or "") or "Ruby运行正常" not in (getattr(runResult, "output", "") or ""):
+ return {"success": False, "message": f"Ruby执行器测试失败: {getattr(runResult, 'output', '')}"}
+ return {"success": True, "message": "Ruby执行器测试成功"}
+ except Exception as error:
+ return {"success": False, "message": f"Ruby执行器测试失败: {str(error)}"}
+
+
+async def testGo():
+ try:
+ goCheckResult = await executeTerminalCommand("go version")
+ if getattr(goCheckResult, "exitCode", 1) != 0 or hasError(getattr(goCheckResult, "output", "") or ""):
+ return {"success": False, "message": "Go不可用,请确保已安装Go"}
+
+ script = 'package main\nimport "fmt"\nfunc main() {\n fmt.Println("Go运行正常")\n}'
+ tempGoDir = "/tmp/test_go_project"
+ tempGoFile = f"{tempGoDir}/main.go"
+ tempGoExec = f"{tempGoDir}/main"
+ await executeTerminalCommand(f"mkdir -p {tempGoDir}")
+ await executeTerminalCommand(f"cat <<'EOF' > {tempGoFile}\n{script}\nEOF")
+
+ compileResult = await executeTerminalCommand(f"cd {tempGoDir} && go build -o main main.go")
+ if getattr(compileResult, "exitCode", 1) != 0 or hasError(getattr(compileResult, "output", "") or ""):
+ await executeTerminalCommand(f"rm -rf {tempGoDir}")
+ return {"success": False, "message": f"Go 编译失败: {getattr(compileResult, 'output', '')}"}
+
+ runResult = await executeTerminalCommand(tempGoExec)
+ await executeTerminalCommand(f"rm -rf {tempGoDir}")
+
+ if getattr(runResult, "exitCode", 1) != 0 or hasError(getattr(runResult, "output", "") or "") or "Go运行正常" not in (getattr(runResult, "output", "") or ""):
+ return {"success": False, "message": f"Go 执行失败: {getattr(runResult, 'output', '')}"}
+ return {"success": True, "message": "Go执行器测试成功"}
+ except Exception as error:
+ return {"success": False, "message": f"Go执行器测试失败: {str(error)}"}
+
+
+async def ensureRustConfigured():
+ rustCheckResult = await executeTerminalCommand("cd /tmp && rustc --version")
+
+ if getattr(rustCheckResult, "exitCode", 1) == 0 and not hasError(getattr(rustCheckResult, "output", "") or ""):
+ return {"success": True, "message": "Rust环境已配置"}
+
+ if "no default is configured" in (getattr(rustCheckResult, "output", "") or ""):
+ setupResult = await executeTerminalCommand('export RUSTUP_DIST_SERVER="https://mirrors.ustc.edu.cn/rust-static" && export RUSTUP_UPDATE_ROOT="https://mirrors.ustc.edu.cn/rust-static/rustup" && rustup default stable')
+ if getattr(setupResult, "exitCode", 1) != 0 or hasError(getattr(setupResult, "output", "") or ""):
+ return {"success": False, "message": f"运行 'rustup default stable' 失败: {getattr(setupResult, 'output', '')}"}
+
+ rustCheckResult = await executeTerminalCommand("cd /tmp && rustc --version")
+ if getattr(rustCheckResult, "exitCode", 1) == 0 and not hasError(getattr(rustCheckResult, "output", "") or ""):
+ return {"success": True, "message": "Rust环境已自动配置"}
+
+ return {"success": False, "message": f"Rust环境检查失败: {getattr(rustCheckResult, 'output', '')}"}
+
+
+async def testRust():
+ try:
+ rustConfig = await ensureRustConfigured()
+ if not rustConfig["success"]:
+ return {"success": False, "message": rustConfig["message"]}
+
+ script = 'fn main() {\n println!("Rust运行正常");\n}'
+ tempRustDir = "/tmp/test_rust_project"
+ tempRustSrcDir = f"{tempRustDir}/src"
+ tempRustFile = f"{tempRustSrcDir}/main.rs"
+ cargoToml = '[package]\nname = "test_rust"\nversion = "0.1.0"\nedition = "2021"\n[dependencies]\n'
+ await executeTerminalCommand(f"mkdir -p {tempRustSrcDir}")
+ await executeTerminalCommand(f"cat <<'EOF' > {tempRustDir}/Cargo.toml\n{cargoToml}\nEOF")
+ await executeTerminalCommand(f"cat <<'EOF' > {tempRustFile}\n{script}\nEOF")
+
+ compileResult = await executeTerminalCommand(f"cd {tempRustDir} && {CARGO_MIRROR_ENV} && cargo build --release")
+ if getattr(compileResult, "exitCode", 1) != 0 or hasError(getattr(compileResult, "output", "") or ""):
+ await executeTerminalCommand(f"rm -rf {tempRustDir}")
+ return {"success": False, "message": f"Rust 编译失败: {getattr(compileResult, 'output', '')}"}
+
+ execPath = f"{tempRustDir}/target/release/test_rust"
+ runResult = await executeTerminalCommand(execPath)
+ await executeTerminalCommand(f"rm -rf {tempRustDir}")
+
+ if getattr(runResult, "exitCode", 1) != 0 or hasError(getattr(runResult, "output", "") or "") or "Rust运行正常" not in (getattr(runResult, "output", "") or ""):
+ return {"success": False, "message": f"Rust 执行失败: {getattr(runResult, 'output', '')}"}
+ return {"success": True, "message": "Rust执行器测试成功"}
+ except Exception as error:
+ return {"success": False, "message": f"Rust执行器测试失败: {str(error)}"}
+
+
+async def testC():
+ try:
+ gccCheckResult = await executeTerminalCommand("gcc --version")
+ if getattr(gccCheckResult, "exitCode", 1) != 0 or hasError(getattr(gccCheckResult, "output", "") or ""):
+ return {"success": False, "message": "GCC不可用,请确保已安装gcc"}
+
+ script = '#include \nint main() {\n printf("C运行正常\\n");\n return 0;\n}'
+ tempCFile = "/tmp/test_c.c"
+ tempCExec = "/tmp/test_c"
+ await executeTerminalCommand(f"cat <<'EOF' > {tempCFile}\n{script}\nEOF")
+
+ compileResult = await executeTerminalCommand(f"gcc -O3 -march=native -fopenmp {tempCFile} -o {tempCExec}")
+ if getattr(compileResult, "exitCode", 1) != 0 or hasError(getattr(compileResult, "output", "") or ""):
+ await executeTerminalCommand(f"rm -f {tempCFile} {tempCExec}")
+ return {"success": False, "message": f"C 编译失败: {getattr(compileResult, 'output', '')}"}
+
+ runResult = await executeTerminalCommand(tempCExec)
+ await executeTerminalCommand(f"rm -f {tempCFile} {tempCExec}")
+
+ if getattr(runResult, "exitCode", 1) != 0 or hasError(getattr(runResult, "output", "") or "") or "C运行正常" not in (getattr(runResult, "output", "") or ""):
+ return {"success": False, "message": f"C 执行失败: {getattr(runResult, 'output', '')}"}
+ return {"success": True, "message": "C执行器测试成功"}
+ except Exception as error:
+ return {"success": False, "message": f"C执行器测试失败: {str(error)}"}
+
+
+async def testCpp():
+ try:
+ gppCheckResult = await executeTerminalCommand("g++ --version")
+ if getattr(gppCheckResult, "exitCode", 1) != 0 or hasError(getattr(gppCheckResult, "output", "") or ""):
+ return {"success": False, "message": "G++不可用,请确保已安装g++"}
+
+ script = '#include \nint main() {\n std::cout << "C++运行正常" << std::endl;\n return 0;\n}'
+ tempCppFile = "/tmp/test_cpp.cpp"
+ tempCppExec = "/tmp/test_cpp"
+ await executeTerminalCommand(f"cat <<'EOF' > {tempCppFile}\n{script}\nEOF")
+
+ compileResult = await executeTerminalCommand(f"g++ -O3 -march=native -fopenmp {tempCppFile} -o {tempCppExec}")
+ if getattr(compileResult, "exitCode", 1) != 0 or hasError(getattr(compileResult, "output", "") or ""):
+ await executeTerminalCommand(f"rm -f {tempCppFile} {tempCppExec}")
+ return {"success": False, "message": f"C++ 编译失败: {getattr(compileResult, 'output', '')}"}
+
+ runResult = await executeTerminalCommand(tempCppExec)
+ await executeTerminalCommand(f"rm -f {tempCppFile} {tempCppExec}")
+
+ if getattr(runResult, "exitCode", 1) != 0 or hasError(getattr(runResult, "output", "") or "") or "C++运行正常" not in (getattr(runResult, "output", "") or ""):
+ return {"success": False, "message": f"C++ 执行失败: {getattr(runResult, 'output', '')}"}
+ return {"success": True, "message": "C++执行器测试成功"}
+ except Exception as error:
+ return {"success": False, "message": f"C++执行器测试失败: {str(error)}"}
+
+
+async def run_javascript_es5(params):
+ script = params.get("script")
+ if not script or script.strip() == "":
+ raise Error("请提供要执行的脚本内容")
+ return await executeJavaScript(script)
+
+
+async def run_javascript_file(params):
+ filePath = params.get("file_path")
+ if not filePath or filePath.strip() == "":
+ raise Error("请提供要执行的 JavaScript 文件路径")
+
+ fileResult = await Tools.Files.read(filePath)
+ if not fileResult or not getattr(fileResult, "content", None):
+ raise Error(f"无法读取文件: {filePath}")
+
+ return await executeJavaScript(fileResult.content)
+
+
+async def run_javascript_node(params):
+ script = params.get("script")
+ if not script or script.strip() == "":
+ raise Error("请提供要执行的 JavaScript 脚本内容")
+
+ ws = await ensurePersistentNodeWorkspace()
+ workspaceDir = ws["workspaceDir"]
+ nodeFlags = params.get("node_flags") or ""
+ tempFileName = f"temp_script_node_{int(time.time() * 1000)}.js"
+ tempFilePath = f"{workspaceDir}/{tempFileName}"
+ try:
+ await executeTerminalCommand(f"cat <<'EOF' > {tempFilePath}\n{script}\nEOF")
+ result = await executeTerminalCommand(f"cd {workspaceDir} && NODE_PATH={workspaceDir}/node_modules node {nodeFlags} {tempFileName}".strip())
+ if getattr(result, "exitCode", 1) == 0 and not hasError(getattr(result, "output", "") or ""):
+ return (getattr(result, "output", "") or "").strip()
+ else:
+ raise Error(f"JavaScript (Node.js) 脚本执行失败:\n{getattr(result, 'output', '')}")
+ finally:
+ try:
+ await executeTerminalCommand(f"rm -f {tempFilePath}")
+ except Exception as err:
+ console.error(f"删除临时文件失败: {str(err)}")
+
+
+async def run_javascript_node_file(params):
+ filePath = params.get("file_path")
+ if not filePath or filePath.strip() == "":
+ raise Error("请提供要执行的 JavaScript 文件路径")
+
+ ws = await ensurePersistentNodeWorkspace()
+ workspaceDir = ws["workspaceDir"]
+ escapedPath = escapeForShell(filePath)
+ fileExistsResult = await executeTerminalCommand(f"test -f '{escapedPath}'")
+ if getattr(fileExistsResult, "exitCode", 1) != 0 or hasError(getattr(fileExistsResult, "output", "") or ""):
+ raise Error(f"JavaScript 文件不存在或路径错误: {filePath}")
+
+ nodeFlags = params.get("node_flags") or ""
+ result = await executeTerminalCommand(f"cd {workspaceDir} && NODE_PATH={workspaceDir}/node_modules node {nodeFlags} '{escapedPath}'".strip())
+ if getattr(result, "exitCode", 1) == 0 and not hasError(getattr(result, "output", "") or ""):
+ return (getattr(result, "output", "") or "").strip()
+ else:
+ raise Error(f"JavaScript (Node.js) 文件执行失败:\n{getattr(result, 'output', '')}")
+
+
+async def run_python(params):
+ script = params.get("script")
+ if not script or script.strip() == "":
+ raise Error("请提供要执行的 Python 脚本内容")
+
+ venv = await ensurePersistentVenv()
+ pythonBin = venv["pythonBin"]
+ pythonFlags = params.get("python_flags") or ""
+ scriptArgs = buildPipeSeparatedShellArgs(params.get("script_args"))
+ tempFilePath = "/tmp/temp_script.py"
+ escapedTempFilePath = escapeForShell(tempFilePath)
+ try:
+ await executeTerminalCommand(f"cat <<'EOF' > {tempFilePath}\n{script}\nEOF")
+ result = await executeTerminalCommand(f"{pythonBin} {pythonFlags} '{escapedTempFilePath}' {scriptArgs}".strip())
+ if getattr(result, "exitCode", 1) == 0 and not hasError(getattr(result, "output", "") or ""):
+ return (getattr(result, "output", "") or "").strip()
+ else:
+ raise Error(f"Python 脚本执行失败:\n{getattr(result, 'output', '')}")
+ finally:
+ try:
+ await executeTerminalCommand(f"rm -f {tempFilePath}")
+ except Exception as err:
+ console.error(f"删除临时文件失败: {str(err)}")
+
+
+async def run_python_file(params):
+ filePath = params.get("file_path")
+ if not filePath or filePath.strip() == "":
+ raise Error("请提供要执行的 Python 文件路径")
+
+ escapedPath = escapeForShell(filePath)
+ fileExistsResult = await executeTerminalCommand(f"test -f '{escapedPath}'")
+ if getattr(fileExistsResult, "exitCode", 1) != 0 or hasError(getattr(fileExistsResult, "output", "") or ""):
+ raise Error(f"Python 文件不存在或路径错误: {filePath}")
+
+ venv = await ensurePersistentVenv()
+ pythonBin = venv["pythonBin"]
+ pythonFlags = params.get("python_flags") or ""
+ scriptArgs = buildPipeSeparatedShellArgs(params.get("script_args"))
+ result = await executeTerminalCommand(f"{pythonBin} {pythonFlags} '{escapedPath}' {scriptArgs}".strip())
+ if getattr(result, "exitCode", 1) == 0 and not hasError(getattr(result, "output", "") or ""):
+ return (getattr(result, "output", "") or "").strip()
+ else:
+ raise Error(f"Python 文件执行失败:\n{getattr(result, 'output', '')}")
+
+
+async def run_ruby(params):
+ script = params.get("script")
+ if not script or script.strip() == "":
+ raise Error("请提供要执行的 Ruby 脚本内容")
+
+ rubyFlags = params.get("ruby_flags") or ""
+ tempFilePath = "/tmp/temp_script.rb"
+ try:
+ await executeTerminalCommand(f"cat <<'EOF' > {tempFilePath}\n{script}\nEOF")
+ result = await executeTerminalCommand(f"ruby {rubyFlags} {tempFilePath}")
+ if getattr(result, "exitCode", 1) == 0 and not hasError(getattr(result, "output", "") or ""):
+ return (getattr(result, "output", "") or "").strip()
+ else:
+ raise Error(f"Ruby 脚本执行失败:\n{getattr(result, 'output', '')}")
+ finally:
+ try:
+ await executeTerminalCommand(f"rm -f {tempFilePath}")
+ except Exception as err:
+ console.error(f"删除临时文件失败: {str(err)}")
+
+
+async def run_ruby_file(params):
+ filePath = params.get("file_path")
+ if not filePath or filePath.strip() == "":
+ raise Error("请提供要执行的 Ruby 文件路径")
+
+ fileExistsResult = await executeTerminalCommand(f"test -f {filePath}")
+ if getattr(fileExistsResult, "exitCode", 1) != 0 or hasError(getattr(fileExistsResult, "output", "") or ""):
+ raise Error(f"Ruby 文件不存在或路径错误: {filePath}")
+
+ rubyFlags = params.get("ruby_flags") or ""
+ result = await executeTerminalCommand(f"ruby {rubyFlags} {filePath}")
+ if getattr(result, "exitCode", 1) == 0 and not hasError(getattr(result, "output", "") or ""):
+ return (getattr(result, "output", "") or "").strip()
+ else:
+ raise Error(f"Ruby 文件执行失败:\n{getattr(result, 'output', '')}")
+
+
+async def run_go(params):
+ script = params.get("script")
+ if not script or script.strip() == "":
+ raise Error("请提供要执行的 Go 代码内容")
+
+ buildFlags = params.get("build_flags") or ""
+ tempDirPath = "/tmp/temp_go"
+ tempFilePath = f"{tempDirPath}/main.go"
+
+ try:
+ await executeTerminalCommand(f"mkdir -p {tempDirPath}")
+ await executeTerminalCommand(f"cat <<'EOF' > {tempFilePath}\n{script}\nEOF")
+
+ compileResult = await executeTerminalCommand(f"cd {tempDirPath} && go build {buildFlags} -o main main.go")
+ if getattr(compileResult, "exitCode", 1) != 0 or hasError(getattr(compileResult, "output", "") or ""):
+ raise Error(f"Go 代码编译失败:\n{getattr(compileResult, 'output', '')}")
+
+ result = await executeTerminalCommand(f"{tempDirPath}/main")
+ if getattr(result, "exitCode", 1) == 0 and not hasError(getattr(result, "output", "") or ""):
+ return (getattr(result, "output", "") or "").strip()
+ else:
+ raise Error(f"Go 代码执行失败:\n{getattr(result, 'output', '')}")
+ finally:
+ try:
+ await executeTerminalCommand(f"rm -rf {tempDirPath}")
+ except Exception as err:
+ console.error(f"删除临时目录失败: {str(err)}")
+
+
+async def run_go_file(params):
+ filePath = params.get("file_path")
+ if not filePath or filePath.strip() == "":
+ raise Error("请提供要执行的 Go 文件路径")
+
+ fileExistsResult = await executeTerminalCommand(f"test -f {filePath}")
+ if getattr(fileExistsResult, "exitCode", 1) != 0 or hasError(getattr(fileExistsResult, "output", "") or ""):
+ raise Error(f"Go 文件不存在或路径错误: {filePath}")
+
+ buildFlags = params.get("build_flags") or ""
+ tempExecPath = "/tmp/temp_go_exec"
+ try:
+ compileResult = await executeTerminalCommand(f"go build {buildFlags} -o {tempExecPath} {filePath}")
+ if getattr(compileResult, "exitCode", 1) != 0 or hasError(getattr(compileResult, "output", "") or ""):
+ raise Error(f"Go 文件编译失败:\n{getattr(compileResult, 'output', '')}")
+
+ result = await executeTerminalCommand(tempExecPath)
+ if getattr(result, "exitCode", 1) == 0 and not hasError(getattr(result, "output", "") or ""):
+ return (getattr(result, "output", "") or "").strip()
+ else:
+ raise Error(f"Go 文件执行失败:\n{getattr(result, 'output', '')}")
+ finally:
+ try:
+ await executeTerminalCommand(f"rm -f {tempExecPath}")
+ except Exception as err:
+ console.error(f"删除临时文件失败: {str(err)}")
+
+
+async def run_rust(params):
+ script = params.get("script")
+ if not script or script.strip() == "":
+ raise Error("请提供要执行的 Rust 代码内容")
+
+ rustConfig = await ensureRustConfigured()
+ if not rustConfig["success"]:
+ raise Error(rustConfig["message"])
+
+ cargoFlags = params.get("cargo_flags") or "--release"
+ buildMode = "release" if "--release" in cargoFlags else "debug"
+ tempDirPath = "/tmp/temp_rust_project"
+ try:
+ cargoToml = '[package]\nname = "temp_rust_script"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n'
+ await executeTerminalCommand(f"mkdir -p {tempDirPath}/src", 10000)
+ await executeTerminalCommand(f"cat <<'EOF' > {tempDirPath}/Cargo.toml\n{cargoToml}\nEOF")
+ await executeTerminalCommand(f"cat <<'EOF' > {tempDirPath}/src/main.rs\n{script}\nEOF")
+
+ compileResult = await executeTerminalCommand(f"cd {tempDirPath} && {CARGO_MIRROR_ENV} && cargo build {cargoFlags}")
+ if getattr(compileResult, "exitCode", 1) != 0 or hasError(getattr(compileResult, "output", "") or ""):
+ raise Error(f"Rust 代码编译失败:\n{getattr(compileResult, 'output', '')}")
+
+ execPath = f"{tempDirPath}/target/{buildMode}/temp_rust_script"
+ result = await executeTerminalCommand(execPath, 30000)
+ if getattr(result, "exitCode", 1) == 0 and not hasError(getattr(result, "output", "") or ""):
+ return (getattr(result, "output", "") or "").strip()
+ else:
+ raise Error(f"Rust 代码执行失败:\n{getattr(result, 'output', '')}")
+ finally:
+ try:
+ await executeTerminalCommand(f"rm -rf {tempDirPath}")
+ except Exception as err:
+ console.error(f"删除临时目录失败: {str(err)}")
+
+
+async def run_rust_file(params):
+ filePath = params.get("file_path")
+ if not filePath or filePath.strip() == "":
+ raise Error("请提供要执行的 Rust 文件路径")
+ fileExistsResult = await executeTerminalCommand(f"test -f {filePath}")
+ if getattr(fileExistsResult, "exitCode", 1) != 0 or hasError(getattr(fileExistsResult, "output", "") or ""):
+ raise Error(f"Rust 文件不存在或路径错误: {filePath}")
+
+ rustConfig = await ensureRustConfigured()
+ if not rustConfig["success"]:
+ raise Error(rustConfig["message"])
+
+ cargoFlags = params.get("cargo_flags") or "--release"
+ buildMode = "release" if "--release" in cargoFlags else "debug"
+ tempDirPath = "/tmp/temp_rust_project"
+ try:
+ cargoToml = '[package]\nname = "temp_rust_script"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\n'
+ await executeTerminalCommand(f"mkdir -p {tempDirPath}/src", 10000)
+ await executeTerminalCommand(f"cat <<'EOF' > {tempDirPath}/Cargo.toml\n{cargoToml}\nEOF")
+
+ readResult = await executeTerminalCommand(f"cat {filePath}")
+ if getattr(readResult, "exitCode", 1) != 0 or hasError(getattr(readResult, "output", "") or ""):
+ raise Error(f"无法读取文件: {filePath}")
+ fileContent = getattr(readResult, "output", "")
+ await executeTerminalCommand(f"cat <<'EOF' > {tempDirPath}/src/main.rs\n{fileContent}\nEOF")
+
+ compileResult = await executeTerminalCommand(f"cd {tempDirPath} && {CARGO_MIRROR_ENV} && cargo build {cargoFlags}")
+ if getattr(compileResult, "exitCode", 1) != 0 or hasError(getattr(compileResult, "output", "") or ""):
+ raise Error(f"Rust 文件编译失败:\n{getattr(compileResult, 'output', '')}")
+
+ execPath = f"{tempDirPath}/target/{buildMode}/temp_rust_script"
+ result = await executeTerminalCommand(execPath, 30000)
+ if getattr(result, "exitCode", 1) == 0 and not hasError(getattr(result, "output", "") or ""):
+ return (getattr(result, "output", "") or "").strip()
+ else:
+ raise Error(f"Rust 项目执行失败:\n{getattr(result, 'output', '')}")
+ finally:
+ try:
+ await executeTerminalCommand(f"rm -rf {tempDirPath}")
+ except Exception as err:
+ console.error(f"删除临时目录失败: {str(err)}")
+
+
+async def run_c(params):
+ script = params.get("script")
+ if not script or script.strip() == "":
+ raise Error("请提供要执行的 C 代码内容")
+
+ compileFlags = params.get("compile_flags") or "-O3 -march=native -fopenmp"
+ tempFilePath = "/tmp/temp_script.c"
+ tempExecPath = "/tmp/temp_script_c_exec"
+ try:
+ await executeTerminalCommand(f"cat <<'EOF' > {tempFilePath}\n{script}\nEOF")
+
+ compileResult = await executeTerminalCommand(f"gcc {compileFlags} {tempFilePath} -o {tempExecPath}")
+ if getattr(compileResult, "exitCode", 1) != 0 or hasError(getattr(compileResult, "output", "") or ""):
+ raise Error(f"C 代码编译失败:\n{getattr(compileResult, 'output', '')}")
+
+ result = await executeTerminalCommand(tempExecPath)
+ if getattr(result, "exitCode", 1) == 0 and not hasError(getattr(result, "output", "") or ""):
+ return (getattr(result, "output", "") or "").strip()
+ else:
+ raise Error(f"C 代码执行失败:\n{getattr(result, 'output', '')}")
+ finally:
+ try:
+ await executeTerminalCommand(f"rm -f {tempFilePath} {tempExecPath}")
+ except Exception as err:
+ console.error(f"删除临时文件失败: {str(err)}")
+
+
+async def run_c_file(params):
+ filePath = params.get("file_path")
+ if not filePath or filePath.strip() == "":
+ raise Error("请提供要执行的 C 文件路径")
+
+ fileExistsResult = await executeTerminalCommand(f"test -f {filePath}")
+ if getattr(fileExistsResult, "exitCode", 1) != 0 or hasError(getattr(fileExistsResult, "output", "") or ""):
+ raise Error(f"C 文件不存在或路径错误: {filePath}")
+
+ compileFlags = params.get("compile_flags") or "-O3 -march=native -fopenmp"
+ tempExecPath = "/tmp/temp_c_exec"
+ try:
+ compileResult = await executeTerminalCommand(f"gcc {compileFlags} {filePath} -o {tempExecPath}")
+ if getattr(compileResult, "exitCode", 1) != 0 or hasError(getattr(compileResult, "output", "") or ""):
+ raise Error(f"C 文件编译失败:\n{getattr(compileResult, 'output', '')}")
+
+ result = await executeTerminalCommand(tempExecPath)
+ if getattr(result, "exitCode", 1) == 0 and not hasError(getattr(result, "output", "") or ""):
+ return (getattr(result, "output", "") or "").strip()
+ else:
+ raise Error(f"C 文件执行失败:\n{getattr(result, 'output', '')}")
+ finally:
+ try:
+ await executeTerminalCommand(f"rm -f {tempExecPath}")
+ except Exception as err:
+ console.error(f"删除临时文件失败: {str(err)}")
+
+
+async def run_cpp(params):
+ script = params.get("script")
+ if not script or script.strip() == "":
+ raise Error("请提供要执行的 C++ 代码内容")
+
+ compileFlags = params.get("compile_flags") or "-O3 -march=native -fopenmp"
+ tempFilePath = "/tmp/temp_script.cpp"
+ tempExecPath = "/tmp/temp_script_cpp_exec"
+ try:
+ await executeTerminalCommand(f"cat <<'EOF' > {tempFilePath}\n{script}\nEOF")
+
+ compileResult = await executeTerminalCommand(f"g++ {compileFlags} {tempFilePath} -o {tempExecPath}")
+ if getattr(compileResult, "exitCode", 1) != 0 or hasError(getattr(compileResult, "output", "") or ""):
+ raise Error(f"C++ 代码编译失败:\n{getattr(compileResult, 'output', '')}")
+
+ result = await executeTerminalCommand(tempExecPath)
+ if getattr(result, "exitCode", 1) == 0 and not hasError(getattr(result, "output", "") or ""):
+ return (getattr(result, "output", "") or "").strip()
+ else:
+ raise Error(f"C++ 代码执行失败:\n{getattr(result, 'output', '')}")
+ finally:
+ try:
+ await executeTerminalCommand(f"rm -f {tempFilePath} {tempExecPath}")
+ except Exception as err:
+ console.error(f"删除临时文件失败: {str(err)}")
+
+
+async def run_cpp_file(params):
+ filePath = params.get("file_path")
+ if not filePath or filePath.strip() == "":
+ raise Error("请提供要执行的 C++ 文件路径")
+
+ fileExistsResult = await executeTerminalCommand(f"test -f {filePath}")
+ if getattr(fileExistsResult, "exitCode", 1) != 0 or hasError(getattr(fileExistsResult, "output", "") or ""):
+ raise Error(f"C++ 文件不存在或路径错误: {filePath}")
+
+ compileFlags = params.get("compile_flags") or "-O3 -march=native -fopenmp"
+ tempExecPath = "/tmp/temp_cpp_exec"
+ try:
+ compileResult = await executeTerminalCommand(f"g++ {compileFlags} {filePath} -o {tempExecPath}")
+ if getattr(compileResult, "exitCode", 1) != 0 or hasError(getattr(compileResult, "output", "") or ""):
+ raise Error(f"C++ 文件编译失败:\n{getattr(compileResult, 'output', '')}")
+
+ result = await executeTerminalCommand(tempExecPath)
+ if getattr(result, "exitCode", 1) == 0 and not hasError(getattr(result, "output", "") or ""):
+ return (getattr(result, "output", "") or "").strip()
+ else:
+ raise Error(f"C++ 文件执行失败:\n{getattr(result, 'output', '')}")
+ finally:
+ try:
+ await executeTerminalCommand(f"rm -f {tempExecPath}")
+ except Exception as err:
+ console.error(f"删除临时文件失败: {str(err)}")
+
+
+def wrap(func):
+ async def wrapped(params):
+ try:
+ result = await func(params)
+ complete({"success": True, "data": result})
+ except Exception as error:
+ import traceback
+ complete({"success": False, "message": str(error), "error_stack": traceback.format_exc()})
+ return wrapped
+
+
+exports.main = wrap(main)
+exports.run_javascript_es5 = wrap(run_javascript_es5)
+exports.run_javascript_file = wrap(run_javascript_file)
+exports.run_javascript_node = wrap(run_javascript_node)
+exports.run_javascript_node_file = wrap(run_javascript_node_file)
+exports.install_node_packages = wrap(install_node_packages)
+exports.install_python_packages = wrap(install_python_packages)
+exports.run_python = wrap(run_python)
+exports.run_python_file = wrap(run_python_file)
+exports.run_ruby = wrap(run_ruby)
+exports.run_ruby_file = wrap(run_ruby_file)
+exports.run_go = wrap(run_go)
+exports.run_go_file = wrap(run_go_file)
+exports.run_rust = wrap(run_rust)
+exports.run_rust_file = wrap(run_rust_file)
+exports.run_c = wrap(run_c)
+exports.run_c_file = wrap(run_c_file)
+exports.run_cpp = wrap(run_cpp)
+exports.run_cpp_file = wrap(run_cpp_file)
diff --git a/operit_py/crossref.py b/operit_py/crossref.py
new file mode 100644
index 0000000..5a0e30b
--- /dev/null
+++ b/operit_py/crossref.py
@@ -0,0 +1,515 @@
+# METADATA
+# {
+# "name": "crossref",
+# "display_name": {
+# "zh": "Crossref 学术文献查询",
+# "en": "Crossref Academic Literature Search"
+# },
+# "description": {
+# "zh": "Crossref 学术文献查询工具,提供 DOI 查询、关键词搜索、作者搜索等功能,帮助用户查找和获取学术文章元数据。",
+# "en": "Crossref scholarly literature search tools: query by DOI, keyword, author, title, ISSN, and retrieve publication metadata."
+# },
+# "category": "Search",
+# "enabledByDefault": true,
+# "tools": [
+# {
+# "name": "search_by_doi",
+# "description": { "zh": "通过 DOI (Digital Object Identifier) 查询文章的详细信息", "en": "Query article details by DOI (Digital Object Identifier)." },
+# "parameters": [
+# { "name": "doi", "description": { "zh": "文章的 DOI 标识符,例如 '10.1038/nature12373'", "en": "DOI identifier, e.g. '10.1038/nature12373'" }, "type": "string", "required": true }
+# ]
+# },
+# {
+# "name": "search_by_keyword",
+# "description": { "zh": "通过关键词搜索学术文章", "en": "Search scholarly articles by keyword." },
+# "parameters": [
+# { "name": "query", "description": { "zh": "搜索关键词", "en": "Search query keyword(s)" }, "type": "string", "required": true },
+# { "name": "rows", "description": { "zh": "返回结果数量,默认 10,最大 100", "en": "Number of results to return (default: 10, max: 100)" }, "type": "number", "required": false },
+# { "name": "sort", "description": { "zh": "排序方式,可选值:'relevance'(相关性), 'score'(评分), 'updated'(更新时间), 'deposited'(提交时间), 'indexed'(索引时间), 'published'(发布时间)", "en": "Sort mode. Options: 'relevance', 'score', 'updated', 'deposited', 'indexed', 'published'." }, "type": "string", "required": false },
+# { "name": "order", "description": { "zh": "排序顺序,可选值:'asc'(升序), 'desc'(降序),默认 'desc'", "en": "Sort order: 'asc' or 'desc' (default: 'desc')." }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "search_by_author",
+# "description": { "zh": "通过作者名字搜索文章", "en": "Search articles by author name." },
+# "parameters": [
+# { "name": "author", "description": { "zh": "作者名字", "en": "Author name" }, "type": "string", "required": true },
+# { "name": "rows", "description": { "zh": "返回结果数量,默认 10,最大 100", "en": "Number of results to return (default 10, max 100)" }, "type": "number", "required": false }
+# ]
+# },
+# {
+# "name": "search_by_title",
+# "description": { "zh": "通过文章标题搜索", "en": "Search articles by title." },
+# "parameters": [
+# { "name": "title", "description": { "zh": "文章标题或标题关键词", "en": "Article title or title keyword(s)" }, "type": "string", "required": true },
+# { "name": "rows", "description": { "zh": "返回结果数量,默认 10,最大 100", "en": "Number of results to return (default 10, max 100)" }, "type": "number", "required": false }
+# ]
+# },
+# {
+# "name": "search_by_issn",
+# "description": { "zh": "通过期刊 ISSN 查询该期刊发表的文章", "en": "Search articles published in a journal by ISSN." },
+# "parameters": [
+# { "name": "issn", "description": { "zh": "期刊的 ISSN 标识符,例如 '1476-4687'", "en": "Journal ISSN identifier, e.g. '1476-4687'" }, "type": "string", "required": true },
+# { "name": "rows", "description": { "zh": "返回结果数量,默认 10,最大 100", "en": "Number of results to return (default 10, max 100)" }, "type": "number", "required": false }
+# ]
+# }
+# ]
+# }
+
+import json
+import re
+from urllib.parse import quote
+
+BASE_URL = "https://api.crossref.org"
+DEFAULT_ROWS = 10
+MAX_ROWS = 100
+
+
+def buildQueryString(params):
+ return "&".join(f"{quote(str(key))}={quote(str(value))}" for key, value in params.items())
+
+
+# 格式化作者信息
+def formatAuthors(authors):
+ if not authors or len(authors) == 0:
+ return "N/A"
+ names = []
+ for author in authors[:5]: # 只显示前5个作者
+ given = author.get("given") or ""
+ family = author.get("family") or ""
+ name = f"{given} {family}".strip()
+ if len(name) > 0:
+ names.append(name)
+ return ", ".join(names)
+
+
+# 格式化日期
+def formatDate(dateParts):
+ if not dateParts or len(dateParts) == 0 or not dateParts[0]:
+ return "N/A"
+ parts = dateParts[0]
+ if len(parts) == 1:
+ return f"{parts[0]}"
+ if len(parts) == 2:
+ return f"{parts[0]}-{str(parts[1]).rjust(2, '0')}"
+ if len(parts) == 3:
+ return f"{parts[0]}-{str(parts[1]).rjust(2, '0')}-{str(parts[2]).rjust(2, '0')}"
+ return "N/A"
+
+
+def _get(obj, key, default=None):
+ if isinstance(obj, dict):
+ return obj.get(key, default)
+ return default
+
+
+# 格式化单篇文章信息
+def formatArticle(item, index=None):
+ lines = []
+
+ if index is not None:
+ lines.append(f"\n=== Article {index + 1} ===")
+
+ # 标题
+ title = (item.get("title")[0] if item.get("title") and len(item.get("title")) > 0 else "No Title")
+ lines.append(f"Title: {title}")
+
+ # DOI
+ if item.get("DOI"):
+ lines.append(f"DOI: {item['DOI']}")
+ lines.append(f"URL: https://doi.org/{item['DOI']}")
+
+ # 作者
+ authors = formatAuthors(item.get("author"))
+ lines.append(f"Authors: {authors}")
+
+ # 发表日期
+ published = _get(item, "published") or {}
+ publishedPrint = _get(item, "published-print") or {}
+ publishedOnline = _get(item, "published-online") or {}
+ publishedDate = formatDate(
+ _get(published, "date-parts") or _get(publishedPrint, "date-parts") or _get(publishedOnline, "date-parts")
+ )
+ lines.append(f"Published: {publishedDate}")
+
+ # 期刊/会议
+ containerTitle = item.get("container-title")
+ if containerTitle and len(containerTitle) > 0:
+ lines.append(f"Journal/Conference: {containerTitle[0]}")
+
+ # ISSN
+ issn = item.get("ISSN")
+ if issn and len(issn) > 0:
+ lines.append(f"ISSN: {', '.join(issn)}")
+
+ # 出版商
+ if item.get("publisher"):
+ lines.append(f"Publisher: {item['publisher']}")
+
+ # 类型
+ if item.get("type"):
+ lines.append(f"Type: {item['type']}")
+
+ # 引用次数
+ if item.get("is-referenced-by-count") is not None:
+ lines.append(f"Citations: {item['is-referenced-by-count']}")
+
+ # 摘要(如果有)
+ if item.get("abstract"):
+ # 移除 HTML 标签
+ abstractText = re.sub(r"<[^>]*>", "", item["abstract"])
+ lines.append(f"Abstract: {abstractText[:500]}{'...' if len(abstractText) > 500 else ''}")
+
+ return "\n".join(lines)
+
+
+async def _httpGet(url, headers):
+ client = OkHttp.newBuilder().build()
+ response = await client.newRequest().url(url).method("GET").headers(headers).build().execute()
+ return response
+
+
+# 通过 DOI 查询文章
+async def searchByDoi(params):
+ doi = params.get("doi")
+
+ if not doi or doi.strip() == "":
+ return {
+ "success": False,
+ "message": "请提供有效的 DOI"
+ }
+
+ try:
+ url = f"{BASE_URL}/works/{quote(doi, safe='')}"
+ response = await _httpGet(url, {
+ "User-Agent": "Operit/1.0 (mailto:support@example.com)"
+ })
+
+ if not response.isSuccessful():
+ return {
+ "success": False,
+ "message": f"查询失败: HTTP {response.statusCode} - {getattr(response, 'statusMessage', '')}"
+ }
+
+ data = json.loads(response.content)
+
+ if data.get("status") == "ok" and data.get("message"):
+ article = formatArticle(data["message"])
+ return {
+ "success": True,
+ "message": "查询成功",
+ "data": article
+ }
+ else:
+ return {
+ "success": False,
+ "message": "未找到该 DOI 对应的文章"
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "message": f"查询失败: {str(error)}"
+ }
+
+
+# 通过关键词搜索文章
+async def searchByKeyword(params):
+ query = params.get("query")
+ rows = params.get("rows") if params.get("rows") is not None else DEFAULT_ROWS
+ sort = params.get("sort") if params.get("sort") is not None else "relevance"
+ order = params.get("order") if params.get("order") is not None else "desc"
+
+ if not query or query.strip() == "":
+ return {
+ "success": False,
+ "message": "请提供有效的搜索关键词"
+ }
+
+ actualRows = min(max(int(rows), 1), MAX_ROWS)
+
+ try:
+ queryString = buildQueryString({
+ "query": query,
+ "rows": str(actualRows),
+ "sort": sort,
+ "order": order
+ })
+
+ url = f"{BASE_URL}/works?{queryString}"
+ response = await _httpGet(url, {
+ "User-Agent": "Operit/1.0 (mailto:support@example.com)"
+ })
+
+ if not response.isSuccessful():
+ return {
+ "success": False,
+ "message": f"搜索失败: HTTP {response.statusCode} - {getattr(response, 'statusMessage', '')}"
+ }
+
+ data = json.loads(response.content)
+
+ if data.get("status") == "ok" and data.get("message") and data["message"].get("items"):
+ items = data["message"]["items"]
+ totalResults = data["message"].get("total-results")
+
+ results = [formatArticle(item, index) for index, item in enumerate(items)]
+
+ summary = f"Found {totalResults} results (showing {len(items)}):\n" + "\n\n".join(results)
+
+ return {
+ "success": True,
+ "message": "搜索成功",
+ "data": summary,
+ "total": totalResults,
+ "count": len(items)
+ }
+ else:
+ return {
+ "success": False,
+ "message": "未找到相关文章"
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "message": f"搜索失败: {str(error)}"
+ }
+
+
+# 通过作者搜索文章
+async def searchByAuthor(params):
+ author = params.get("author")
+ rows = params.get("rows") if params.get("rows") is not None else DEFAULT_ROWS
+
+ if not author or author.strip() == "":
+ return {
+ "success": False,
+ "message": "请提供有效的作者名字"
+ }
+
+ actualRows = min(max(int(rows), 1), MAX_ROWS)
+
+ try:
+ queryString = buildQueryString({
+ "query.author": author,
+ "rows": str(actualRows)
+ })
+
+ url = f"{BASE_URL}/works?{queryString}"
+ response = await _httpGet(url, {
+ "User-Agent": "Operit/1.0 (mailto:support@example.com)"
+ })
+
+ if not response.isSuccessful():
+ return {
+ "success": False,
+ "message": f"搜索失败: HTTP {response.statusCode} - {getattr(response, 'statusMessage', '')}"
+ }
+
+ data = json.loads(response.content)
+
+ if data.get("status") == "ok" and data.get("message") and data["message"].get("items"):
+ items = data["message"]["items"]
+ totalResults = data["message"].get("total-results")
+
+ results = [formatArticle(item, index) for index, item in enumerate(items)]
+
+ summary = f"Found {totalResults} results for author \"{author}\" (showing {len(items)}):\n" + "\n\n".join(results)
+
+ return {
+ "success": True,
+ "message": "搜索成功",
+ "data": summary,
+ "total": totalResults,
+ "count": len(items)
+ }
+ else:
+ return {
+ "success": False,
+ "message": f"未找到该作者 \"{author}\" 的文章"
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "message": f"搜索失败: {str(error)}"
+ }
+
+
+# 通过标题搜索文章
+async def searchByTitle(params):
+ title = params.get("title")
+ rows = params.get("rows") if params.get("rows") is not None else DEFAULT_ROWS
+
+ if not title or title.strip() == "":
+ return {
+ "success": False,
+ "message": "请提供有效的文章标题"
+ }
+
+ actualRows = min(max(int(rows), 1), MAX_ROWS)
+
+ try:
+ queryString = buildQueryString({
+ "query.title": title,
+ "rows": str(actualRows)
+ })
+
+ url = f"{BASE_URL}/works?{queryString}"
+ response = await _httpGet(url, {
+ "User-Agent": "Operit/1.0 (mailto:support@example.com)"
+ })
+
+ data = json.loads(response.content)
+
+ if data.get("status") == "ok" and data.get("message") and data["message"].get("items"):
+ items = data["message"]["items"]
+ totalResults = data["message"].get("total-results")
+
+ results = [formatArticle(item, index) for index, item in enumerate(items)]
+
+ summary = f"Found {totalResults} results matching title \"{title}\" (showing {len(items)}):\n" + "\n\n".join(results)
+
+ return {
+ "success": True,
+ "message": "搜索成功",
+ "data": summary,
+ "total": totalResults,
+ "count": len(items)
+ }
+ else:
+ return {
+ "success": False,
+ "message": f"未找到标题包含 \"{title}\" 的文章"
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "message": f"搜索失败: {str(error)}"
+ }
+
+
+# 通过 ISSN 查询期刊文章
+async def searchByISSN(params):
+ issn = params.get("issn")
+ rows = params.get("rows") if params.get("rows") is not None else DEFAULT_ROWS
+
+ if not issn or issn.strip() == "":
+ return {
+ "success": False,
+ "message": "请提供有效的 ISSN"
+ }
+
+ actualRows = min(max(int(rows), 1), MAX_ROWS)
+
+ try:
+ url = f"{BASE_URL}/journals/{quote(issn, safe='')}/works?rows={actualRows}"
+ response = await _httpGet(url, {
+ "User-Agent": "Operit/1.0 (mailto:support@example.com)"
+ })
+
+ if not response.isSuccessful():
+ return {
+ "success": False,
+ "message": f"查询失败: HTTP {response.statusCode} - {getattr(response, 'statusMessage', '')}"
+ }
+
+ data = json.loads(response.content)
+
+ if data.get("status") == "ok" and data.get("message") and data["message"].get("items"):
+ items = data["message"]["items"]
+ totalResults = data["message"].get("total-results")
+
+ results = [formatArticle(item, index) for index, item in enumerate(items)]
+
+ summary = f"Found {totalResults} articles from journal ISSN {issn} (showing {len(items)}):\n" + "\n\n".join(results)
+
+ return {
+ "success": True,
+ "message": "查询成功",
+ "data": summary,
+ "total": totalResults,
+ "count": len(items)
+ }
+ else:
+ return {
+ "success": False,
+ "message": f"未找到 ISSN \"{issn}\" 对应期刊的文章"
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "message": f"查询失败: {str(error)}"
+ }
+
+
+# 包装函数,统一处理错误
+async def wrapToolExecution(func, params):
+ try:
+ result = await func(params)
+ complete(result)
+ except Exception as error:
+ console.error("工具执行失败", error)
+ complete({
+ "success": False,
+ "message": f"工具执行时发生意外错误: {str(error)}",
+ })
+
+
+# 测试函数
+async def main(params=None):
+ if params is None:
+ params = {}
+ console.log("=== Crossref API 测试 ===\n")
+
+ # 测试 1: 通过 DOI 查询
+ console.log("1. 测试通过 DOI 查询...")
+ doiResult = await searchByDoi({"doi": "10.1038/nature12373"})
+ console.log(json.dumps(doiResult, ensure_ascii=False, indent=2))
+ console.log("\n")
+
+ # 测试 2: 通过关键词搜索
+ console.log("2. 测试通过关键词搜索...")
+ keywordResult = await searchByKeyword({"query": "machine learning", "rows": 3})
+ console.log(json.dumps(keywordResult, ensure_ascii=False, indent=2))
+ console.log("\n")
+
+ # 测试 3: 通过作者搜索
+ console.log("3. 测试通过作者搜索...")
+ authorResult = await searchByAuthor({"author": "John Smith", "rows": 3})
+ console.log(json.dumps(authorResult, ensure_ascii=False, indent=2))
+ console.log("\n")
+
+ # 测试 4: 通过标题搜索
+ console.log("4. 测试通过标题搜索...")
+ titleResult = await searchByTitle({"title": "neural networks", "rows": 3})
+ console.log(json.dumps(titleResult, ensure_ascii=False, indent=2))
+ console.log("\n")
+
+ console.log("=== 测试完成 ===")
+
+
+async def _exported_search_by_doi(params):
+ await wrapToolExecution(searchByDoi, params)
+
+
+async def _exported_search_by_keyword(params):
+ await wrapToolExecution(searchByKeyword, params)
+
+
+async def _exported_search_by_author(params):
+ await wrapToolExecution(searchByAuthor, params)
+
+
+async def _exported_search_by_title(params):
+ await wrapToolExecution(searchByTitle, params)
+
+
+async def _exported_search_by_issn(params):
+ await wrapToolExecution(searchByISSN, params)
+
+
+exports.search_by_doi = _exported_search_by_doi
+exports.search_by_keyword = _exported_search_by_keyword
+exports.search_by_author = _exported_search_by_author
+exports.search_by_title = _exported_search_by_title
+exports.search_by_issn = _exported_search_by_issn
+exports.main = main
diff --git a/operit_py/daily_life.py b/operit_py/daily_life.py
new file mode 100644
index 0000000..ce7afb4
--- /dev/null
+++ b/operit_py/daily_life.py
@@ -0,0 +1,860 @@
+# METADATA
+# {
+# "name": "daily_life",
+# "display_name": { "zh": "日常生活工具包", "en": "Daily Life Toolkit" },
+# "description": { "zh": "日常生活工具集合:日期时间、设备状态、电量/内存概况、天气查询、提醒、闹钟、短信、电话、微信单条消息发送、QQ单条消息发送、朋友圈发布、手电筒、音量调节、Wi-Fi 开关、截图、拍照、深色模式、指定时间唤醒 AI 执行一次性定时任务。", "en": "Daily life utilities: date/time, device status, battery/memory overview, weather lookup, reminders, alarms, SMS, calls, single-message WeChat send, single-message QQ send, Moments posting, flashlight, volume control, Wi-Fi toggle, screenshots, photos, dark mode, wake AI at a specified time for a one-time scheduled task." },
+# "enabledByDefault": true,
+# "category": "Life",
+# "tools": [
+# { "name": "get_current_date", "description": { "zh": "获取当前日期和时间,支持多种格式展示", "en": "Get the current date and time in various formats." }, "parameters": [ { "name": "format", "description": { "zh": "日期格式('short'简短格式, 'medium'中等格式, 'long'完整格式,或自定义格式)", "en": "Date format: 'short', 'medium', 'long', or a custom format." }, "type": "string", "required": false } ] },
+# { "name": "device_status", "description": { "zh": "获取设备状态信息,包括电池和内存使用情况", "en": "Get device status information, including battery and memory usage." }, "parameters": [] },
+# { "name": "set_reminder", "description": { "zh": "创建提醒或待办事项。", "en": "Create a reminder or to-do item." }, "parameters": [ { "name": "title", "type": "string", "required": true }, { "name": "description", "type": "string", "required": false }, { "name": "due_date", "type": "string", "required": false } ] },
+# { "name": "schedule_one_time_task", "description": { "zh": "在指定时间唤醒 AI 执行一次性定时任务。", "en": "Wake the AI at a specified time to execute a one-time scheduled task." }, "parameters": [ { "name": "trigger_time", "type": "string", "required": true }, { "name": "message", "type": "string", "required": false } ] },
+# { "name": "set_alarm", "description": { "zh": "在设备上设置闹钟。", "en": "Set an alarm on the device." }, "parameters": [ { "name": "hour", "type": "number", "required": true }, { "name": "minute", "type": "number", "required": true }, { "name": "message", "type": "string", "required": true }, { "name": "days", "type": "array", "required": false } ] },
+# { "name": "send_message", "description": { "zh": "发送短信", "en": "Send an SMS message." }, "parameters": [ { "name": "phone_number", "type": "string", "required": true }, { "name": "message", "type": "string", "required": true } ] },
+# { "name": "wechat_send_message", "description": { "zh": "通过微信发送文本消息", "en": "Send a text message via WeChat." }, "parameters": [ { "name": "message", "type": "string", "required": true } ] },
+# { "name": "qq_send_message", "description": { "zh": "通过QQ发送文本消息", "en": "Send a text message via QQ." }, "parameters": [ { "name": "message", "type": "string", "required": true } ] },
+# { "name": "wechat_post_moments", "description": { "zh": "通过微信朋友圈发表文本内容", "en": "Post text to WeChat Moments." }, "parameters": [ { "name": "message", "type": "string", "required": true } ] },
+# { "name": "make_phone_call", "description": { "zh": "拨打电话", "en": "Make a phone call." }, "parameters": [ { "name": "phone_number", "type": "string", "required": true }, { "name": "emergency", "type": "boolean", "required": false } ] },
+# { "name": "search_weather", "description": { "zh": "搜索当前天气信息", "en": "Search current weather information." }, "parameters": [ { "name": "location", "type": "string", "required": false } ] },
+# { "name": "toggle_flashlight", "description": { "zh": "打开或关闭手电筒", "en": "Turn the flashlight on or off." }, "parameters": [ { "name": "state", "type": "string", "required": true } ] },
+# { "name": "adjust_volume", "description": { "zh": "调节设备音量", "en": "Adjust device volume." }, "parameters": [ { "name": "action", "type": "string", "required": true }, { "name": "count", "type": "number", "required": false } ] },
+# { "name": "toggle_wifi", "description": { "zh": "打开或关闭Wi-Fi", "en": "Turn Wi-Fi on or off." }, "parameters": [ { "name": "state", "type": "string", "required": true } ] },
+# { "name": "take_screenshot", "description": { "zh": "截取当前屏幕", "en": "Take a screenshot." }, "parameters": [ { "name": "file_path", "type": "string", "required": false } ] },
+# { "name": "take_photo", "description": { "zh": "打开相机应用拍照", "en": "Open the camera app to take a photo." }, "parameters": [] },
+# { "name": "toggle_dark_mode", "description": { "zh": "切换系统深夜模式", "en": "Toggle system dark mode." }, "parameters": [ { "name": "state", "type": "string", "required": true } ] }
+# ]
+# }
+
+import json
+import re
+import time
+import math
+import asyncio
+from datetime import datetime, timezone, timedelta
+from urllib.parse import quote
+
+Error = Exception
+
+
+# Android Intent 降级 stub(此功能仅在 Android 上可用)
+class IntentAction:
+ ACTION_INSERT = "android.intent.action.INSERT"
+ ACTION_SENDTO = "android.intent.action.SENDTO"
+ ACTION_SEND = "android.intent.action.SEND"
+ ACTION_DIAL = "android.intent.action.DIAL"
+ ACTION_CALL_EMERGENCY = "android.intent.action.CALL_EMERGENCY"
+ ACTION_IMAGE_CAPTURE = "android.media.action.IMAGE_CAPTURE"
+
+
+class IntentFlag:
+ ACTIVITY_NEW_TASK = 0x10000000
+
+
+class Intent:
+ """Android Intent 降级 stub - 仅在 Android 上可用"""
+
+ def __init__(self, action=None):
+ self._action = action
+ self._data = None
+ self._type = None
+ self._extras = {}
+ self._flags = 0
+ self._categories = []
+ self._component = None
+
+ def setData(self, data):
+ self._data = data
+ return self
+
+ def setType(self, mime_type):
+ self._type = mime_type
+ return self
+
+ def putExtra(self, key, value):
+ self._extras[key] = value
+ return self
+
+ def addFlag(self, flag):
+ self._flags |= flag
+ return self
+
+ def addCategory(self, category):
+ self._categories.append(category)
+ return self
+
+ def setComponent(self, pkg, cls):
+ self._component = (pkg, cls)
+ return self
+
+ async def start(self):
+ return {"success": False, "message": "此功能仅在 Android 上可用"}
+
+
+async def get_current_date(params):
+ try:
+ fmt = params.get("format", "medium") if params else "medium"
+ now = datetime.now()
+
+ if fmt == "short":
+ formattedDate = now.strftime("%Y/%m/%d")
+ elif fmt == "long":
+ formattedDate = now.strftime("%Y年%m月%d日 %A %H:%M:%S")
+ else:
+ formattedDate = now.strftime("%Y年%m月%d日 %H:%M")
+
+ weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
+ return {
+ "timestamp": int(now.timestamp() * 1000),
+ "iso": now.isoformat(),
+ "formatted": formattedDate,
+ "date": {
+ "year": now.year,
+ "month": now.month,
+ "day": now.day,
+ "weekday": weekdays[now.weekday()]
+ },
+ "time": {
+ "hours": now.hour,
+ "minutes": now.minute,
+ "seconds": now.second
+ }
+ }
+ except Exception as error:
+ console.error(f"[get_current_date] 错误: {error}")
+ import traceback
+ console.error(traceback.format_exc())
+ raise error
+
+
+async def device_status():
+ try:
+ deviceInfo = await Tools.System.getDeviceInfo()
+ return {
+ "battery": {
+ "level": deviceInfo.batteryLevel,
+ "charging": deviceInfo.batteryCharging
+ },
+ "memory": {
+ "total": deviceInfo.totalMemory,
+ "available": deviceInfo.availableMemory
+ },
+ "storage": {
+ "total": deviceInfo.totalStorage,
+ "available": deviceInfo.availableStorage
+ },
+ "device": {
+ "model": deviceInfo.model,
+ "manufacturer": deviceInfo.manufacturer,
+ "androidVersion": deviceInfo.androidVersion
+ },
+ "network": deviceInfo.networkType
+ }
+ except Exception as error:
+ raise Error(f"Failed to get device status: {error}")
+
+
+def extractWeatherInfo(content):
+ cleanContent = re.sub(r'?[^>]+(>|$)', " ", content)
+ cleanContent = re.sub(r'\s+', " ", cleanContent).strip()
+ maxLength = 1500
+ if len(cleanContent) > maxLength:
+ return cleanContent[:maxLength] + "..."
+ return cleanContent
+
+
+async def search_weather(params):
+ try:
+ location = (params.get("location") if params else None) or "current"
+ if location == "current":
+ searchUrl = "https://www.baidu.com/s?wd=当前天气"
+ else:
+ searchUrl = f"https://www.baidu.com/s?wd={quote(location + ' 天气')}"
+
+ console.log(f"搜索天气信息: {searchUrl}")
+ result = await Tools.Net.visit(searchUrl)
+ extractedInfo = extractWeatherInfo(result.content)
+
+ return {
+ "success": True,
+ "query": "当前天气" if location == "current" else f"{location} 天气",
+ "location": location,
+ "timestamp": datetime.now().isoformat(),
+ "url": result.url,
+ "title": result.title,
+ "weather_info": extractedInfo,
+ "note": "天气数据来自网页内容提取,仅供参考。"
+ }
+ except Exception as error:
+ console.error(f"[search_weather] 错误: {error}")
+ import traceback
+ console.error(traceback.format_exc())
+ raise Error(f"获取天气信息失败: {error}")
+
+
+async def set_reminder(params):
+ try:
+ if not params or not params.get("title"):
+ raise Error("Reminder title is required")
+
+ console.log("创建提醒...")
+ console.log("尝试使用隐式Intent创建日历事件...")
+
+ intent = Intent(IntentAction.ACTION_INSERT)
+ intent.setData("content://com.android.calendar/events")
+ intent.putExtra("title", params["title"])
+ if params.get("description"):
+ intent.putExtra("description", params["description"])
+
+ if params.get("due_date"):
+ dueDate = datetime.fromisoformat(params["due_date"])
+ beginTime = int(dueDate.timestamp() * 1000)
+ endTime = beginTime + 3600000
+ intent.putExtra("beginTime", beginTime)
+ intent.putExtra("endTime", endTime)
+ intent.putExtra("eventTimezone", "UTC")
+ intent.putExtra("allDay", False)
+ else:
+ now = datetime.now()
+ beginTime = int(now.timestamp() * 1000)
+ endTime = beginTime + 3600000
+ intent.putExtra("beginTime", beginTime)
+ intent.putExtra("endTime", endTime)
+ intent.putExtra("eventTimezone", "UTC")
+ intent.putExtra("allDay", False)
+
+ intent.putExtra("hasAlarm", 1)
+ intent.addFlag(IntentFlag.ACTIVITY_NEW_TASK)
+ result = await intent.start()
+
+ return {
+ "success": True,
+ "message": "提醒创建成功",
+ "title": params["title"],
+ "description": params.get("description"),
+ "due_date": params.get("due_date"),
+ "method": "implicit_intent",
+ "raw_result": result
+ }
+ except Exception as error:
+ console.error(f"[set_reminder] 错误: {error}")
+ import traceback
+ console.error(traceback.format_exc())
+ return {
+ "success": False,
+ "message": f"创建提醒失败: {error}",
+ "title": params.get("title") if params else None,
+ "description": params.get("description") if params else None,
+ "due_date": params.get("due_date") if params else None,
+ "error": str(error)
+ }
+
+
+async def schedule_one_time_task(params):
+ if not params or not params.get("trigger_time"):
+ raise Error("trigger_time is required")
+
+ resolvedChatId = getChatId()
+ if not resolvedChatId:
+ raise Error("chat_id is required (call from a chat context)")
+
+ resolvedRoleCardId = getCallerCardId()
+ resolvedSenderName = getCallerName()
+ resolvedLang = getLang()
+ workflowName = (f"一次性定时任务 {params['trigger_time']}"
+ if resolvedLang and resolvedLang.lower().startswith("zh")
+ else f"One-time scheduled task {params['trigger_time']}")
+ description = (f"一次性定时任务,触发时间 {params['trigger_time']}"
+ if resolvedLang and resolvedLang.lower().startswith("zh")
+ else f"One-time scheduled task at {params['trigger_time']}")
+ timedTriggerTag = f"[定时触发:{params['trigger_time']}]\n[Scheduled Trigger:{params['trigger_time']}]"
+ defaultMessage = (f"[自动任务] 时间到了,开始执行一次性定时任务。现在是 {params['trigger_time']}\n"
+ f"[Automated Task] Time reached, start executing the one-time scheduled task. Current time is {params['trigger_time']}")
+ messageContent = (f"{params['message']}\n{timedTriggerTag}"
+ if params.get("message")
+ else f"{defaultMessage}\n{timedTriggerTag}")
+
+ created = await Tools.Workflow.create(workflowName, description, None, None, True)
+ workflowId = created.id if created else None
+ if not workflowId:
+ raise Error("Failed to create workflow: missing workflow id")
+
+ uniqueId = str(int(time.time() * 1000))
+ triggerNodeId = f"trigger_{uniqueId}"
+ startNodeId = f"start_{uniqueId}"
+ sendNodeId = f"send_{uniqueId}"
+ deleteNodeId = f"delete_{uniqueId}"
+
+ actionConfig = {
+ "message": messageContent,
+ "chat_id": resolvedChatId,
+ "hide_user_message": True,
+ "notify_reply": True,
+ "persist_turn": True,
+ "disable_warning": False,
+ }
+ if resolvedRoleCardId:
+ actionConfig["role_card_id"] = resolvedRoleCardId
+ if resolvedSenderName:
+ actionConfig["sender_name"] = resolvedSenderName
+
+ if resolvedLang and resolvedLang.lower().startswith("zh"):
+ nodeTexts = {
+ "triggerName": "定时触发", "triggerDesc": "一次性定时触发",
+ "startName": "启动对话服务", "startDesc": "启动对话服务以便发送消息",
+ "sendName": "唤醒AI", "sendDesc": "向对话发送唤醒消息,触发AI执行任务",
+ "deleteName": "删除工作流", "deleteDesc": "执行后删除自己"
+ }
+ else:
+ nodeTexts = {
+ "triggerName": "Scheduled Trigger", "triggerDesc": "One-time scheduled trigger",
+ "startName": "Start Chat Service", "startDesc": "Start chat service to enable messaging",
+ "sendName": "Wake AI", "sendDesc": "Send a wake message to trigger AI task execution",
+ "deleteName": "Delete Workflow", "deleteDesc": "Delete itself after execution"
+ }
+
+ nodes = [
+ {
+ "id": triggerNodeId, "type": "trigger", "name": nodeTexts["triggerName"],
+ "description": nodeTexts["triggerDesc"], "position": {"x": 0, "y": 0},
+ "triggerType": "schedule",
+ "triggerConfig": {
+ "schedule_type": "specific_time", "specific_time": params["trigger_time"],
+ "repeat": "false", "enabled": "true"
+ }
+ },
+ {
+ "id": startNodeId, "type": "execute", "name": nodeTexts["startName"],
+ "description": nodeTexts["startDesc"], "position": {"x": 260, "y": 0},
+ "actionType": "start_chat_service",
+ "actionConfig": {"initial_mode": "BALL", "keep_if_exists": "true"}
+ },
+ {
+ "id": sendNodeId, "type": "execute", "name": nodeTexts["sendName"],
+ "description": nodeTexts["sendDesc"], "position": {"x": 520, "y": 0},
+ "actionType": "send_message_to_ai", "actionConfig": actionConfig
+ },
+ {
+ "id": deleteNodeId, "type": "execute", "name": nodeTexts["deleteName"],
+ "description": nodeTexts["deleteDesc"], "position": {"x": 1040, "y": 0},
+ "actionType": "delete_workflow", "actionConfig": {"workflow_id": workflowId}
+ }
+ ]
+
+ connections = [
+ {"sourceNodeId": triggerNodeId, "targetNodeId": startNodeId, "condition": "on_success"},
+ {"sourceNodeId": startNodeId, "targetNodeId": sendNodeId, "condition": "on_success"},
+ {"sourceNodeId": sendNodeId, "targetNodeId": deleteNodeId, "condition": "on_success"}
+ ]
+
+ await Tools.Workflow.update(workflowId, {
+ "name": workflowName, "description": description,
+ "enabled": True, "nodes": nodes, "connections": connections
+ })
+
+ return {
+ "success": True,
+ "message": "一次性提醒工作流已创建",
+ "data": {
+ "workflow_id": workflowId,
+ "trigger_time": params["trigger_time"],
+ "chat_id": resolvedChatId,
+ "role_card_id": resolvedRoleCardId,
+ "workflow_name": workflowName
+ }
+ }
+
+
+async def set_alarm(params):
+ try:
+ if params.get("hour") is None or params.get("minute") is None:
+ raise Error("Hour and minute are required for setting an alarm")
+ repeatDays = [d for d in params.get("days", []) if isinstance(d, (int, float))] if isinstance(params.get("days"), list) else []
+ hour = params["hour"]
+ minute = params["minute"]
+ if hour < 0 or hour > 23:
+ raise Error("Hour must be between 0 and 23")
+ if minute < 0 or minute > 59:
+ raise Error("Minute must be between 0 and 59")
+
+ console.log(f"设置闹钟...{hour}:{minute}")
+ console.log("尝试使用隐式Intent设置闹钟...")
+
+ intent = Intent("android.intent.action.SET_ALARM")
+ intent.putExtra("android.intent.extra.alarm.HOUR", hour)
+ intent.putExtra("android.intent.extra.alarm.MINUTES", minute)
+ intent.putExtra("android.intent.extra.alarm.MESSAGE", params.get("message", ""))
+ if len(repeatDays) > 0:
+ intent.putExtra("android.intent.extra.alarm.DAYS", repeatDays)
+ intent.putExtra("android.intent.extra.alarm.VIBRATE", True)
+ intent.addFlag(IntentFlag.ACTIVITY_NEW_TASK)
+ intent.addCategory("android.intent.category.DEFAULT")
+ result = await intent.start()
+
+ return {
+ "success": True,
+ "message": "闹钟设置成功",
+ "alarm_time": f"{str(hour).zfill(2)}:{str(minute).zfill(2)}",
+ "label": params.get("message"),
+ "repeat_days": params.get("days"),
+ "method": "implicit_intent",
+ "raw_result": result
+ }
+ except Exception as error:
+ console.error(f"[set_alarm] 错误: {error}")
+ import traceback
+ console.error(traceback.format_exc())
+ hour = params.get("hour", 0) if params else 0
+ minute = params.get("minute", 0) if params else 0
+ return {
+ "success": False,
+ "message": f"设置闹钟失败: {error}",
+ "alarm_time": f"{str(hour).zfill(2)}:{str(minute).zfill(2)}",
+ "label": params.get("message") if params else None,
+ "repeat_days": params.get("days") if params else None,
+ "error": str(error)
+ }
+
+
+async def send_message(params):
+ try:
+ if not params or not params.get("phone_number"):
+ raise Error("Phone number is required")
+ if not params.get("message"):
+ raise Error("Message content is required")
+
+ console.log(f"发送短信: {params['phone_number']}")
+ intent = Intent(IntentAction.ACTION_SENDTO)
+ intent.setData(f"smsto:{params['phone_number']}")
+ intent.putExtra("sms_body", params["message"])
+ intent.addFlag(IntentFlag.ACTIVITY_NEW_TASK)
+ result = await intent.start()
+
+ msg = params["message"]
+ return {
+ "success": True,
+ "message": "短信编辑界面已打开",
+ "phone_number": params["phone_number"],
+ "content_preview": msg[:30] + "..." if len(msg) > 30 else msg,
+ "raw_result": result
+ }
+ except Exception as error:
+ console.error(f"发送短信失败: {error}")
+ return {
+ "success": False,
+ "message": f"发送短信失败: {error}",
+ "phone_number": params.get("phone_number") if params else None
+ }
+
+
+async def wechat_send_message(params):
+ try:
+ if not params or not params.get("message"):
+ raise Error("Message content is required")
+ console.log("通过 Intent 调起微信发送文本消息...")
+ intent = Intent(IntentAction.ACTION_SEND)
+ intent.setType("text/plain")
+ intent.putExtra("android.intent.extra.TEXT", params["message"])
+ intent.setComponent("com.tencent.mm", "com.tencent.mm.ui.tools.ShareImgUI")
+ intent.addFlag(IntentFlag.ACTIVITY_NEW_TASK)
+ result = await intent.start()
+ msg = params["message"]
+ return {
+ "success": True,
+ "message": "已打开微信分享界面,请选择联系人并确认发送",
+ "content_preview": msg[:30] + "..." if len(msg) > 30 else msg,
+ "raw_result": result
+ }
+ except Exception as error:
+ console.error(f"[wechat_send_message] 错误: {error}")
+ import traceback
+ console.error(traceback.format_exc())
+ msg = params.get("message", "") if params else ""
+ return {
+ "success": False,
+ "message": f"调用微信发送消息失败: {error}",
+ "content_preview": msg[:30] + "..." if len(msg) > 30 else msg
+ }
+
+
+async def qq_send_message(params):
+ try:
+ if not params or not params.get("message"):
+ raise Error("Message content is required")
+ console.log("通过 Intent 调起 QQ 发送文本消息...")
+ intent = Intent(IntentAction.ACTION_SEND)
+ intent.setType("text/plain")
+ intent.putExtra("android.intent.extra.TEXT", params["message"])
+ intent.setComponent("com.tencent.mobileqq", "com.tencent.mobileqq.activity.JumpActivity")
+ intent.addFlag(IntentFlag.ACTIVITY_NEW_TASK)
+ result = await intent.start()
+ msg = params["message"]
+ return {
+ "success": True,
+ "message": "已打开 QQ 分享界面,请选择联系人并确认发送",
+ "content_preview": msg[:30] + "..." if len(msg) > 30 else msg,
+ "raw_result": result
+ }
+ except Exception as error:
+ console.error(f"[qq_send_message] 错误: {error}")
+ import traceback
+ console.error(traceback.format_exc())
+ msg = params.get("message", "") if params else ""
+ return {
+ "success": False,
+ "message": f"调用 QQ 发送消息失败: {error}",
+ "content_preview": msg[:30] + "..." if len(msg) > 30 else msg
+ }
+
+
+async def wechat_post_moments(params):
+ try:
+ if not params or not params.get("message"):
+ raise Error("Message content is required")
+ console.log("通过 Intent 调起微信朋友圈发表界面...")
+ intent = Intent(IntentAction.ACTION_SEND)
+ intent.setType("text/plain")
+ intent.setComponent("com.tencent.mm", "com.tencent.mm.ui.tools.ShareToTimeLineUI")
+ intent.putExtra("android.intent.extra.TEXT", params["message"])
+ intent.putExtra("Kdescription", params["message"])
+ intent.addFlag(IntentFlag.ACTIVITY_NEW_TASK)
+ result = await intent.start()
+ msg = params["message"]
+ return {
+ "success": True,
+ "message": "已打开微信朋友圈发表界面,请确认内容并发送",
+ "content_preview": msg[:30] + "..." if len(msg) > 30 else msg,
+ "raw_result": result
+ }
+ except Exception as error:
+ console.error(f"[wechat_post_moments] 错误: {error}")
+ import traceback
+ console.error(traceback.format_exc())
+ msg = params.get("message", "") if params else ""
+ return {
+ "success": False,
+ "message": f"调用微信朋友圈失败: {error}",
+ "content_preview": msg[:30] + "..." if len(msg) > 30 else msg
+ }
+
+
+async def make_phone_call(params):
+ try:
+ if not params or not params.get("phone_number"):
+ raise Error("Phone number is required")
+ console.log(f"拨打电话: {params['phone_number']}")
+ isEmergency = params.get("emergency") is True
+ action = IntentAction.ACTION_CALL_EMERGENCY if isEmergency else IntentAction.ACTION_DIAL
+ intent = Intent(action)
+ intent.setData(f"tel:{params['phone_number']}")
+ intent.addFlag(IntentFlag.ACTIVITY_NEW_TASK)
+ result = await intent.start()
+ return {
+ "success": True,
+ "message": "紧急电话已拨打" if isEmergency else "拨号界面已打开",
+ "phone_number": params["phone_number"],
+ "is_emergency": isEmergency,
+ "raw_result": result
+ }
+ except Exception as error:
+ console.error(f"拨打电话失败: {error}")
+ return {
+ "success": False,
+ "message": f"拨打电话失败: {error}",
+ "phone_number": params.get("phone_number") if params else None,
+ "is_emergency": params.get("emergency") is True if params else False
+ }
+
+
+async def toggle_flashlight(params):
+ try:
+ if not params or not params.get("state"):
+ raise Error("Flashlight state is required")
+ state = params["state"].lower()
+ if state not in ("on", "off"):
+ raise Error("Invalid state. Must be 'on' or 'off'")
+ console.log(f"{'打开' if state == 'on' else '关闭'}手电筒...")
+ stateValue = "1" if state == "on" else "0"
+ flashStateResult = await Tools.System.setSetting("FlashState", stateValue, "system")
+ console.log(f"FlashState 设置结果: {json.dumps(flashStateResult)}")
+ backFlashlightResult = await Tools.System.setSetting("back_flashlight_state", stateValue, "system")
+ console.log(f"back_flashlight_state 设置结果: {json.dumps(backFlashlightResult)}")
+ return {
+ "success": True,
+ "message": f"手电筒已{'打开' if state == 'on' else '关闭'}",
+ "state": state,
+ "flash_state_result": flashStateResult,
+ "back_flashlight_result": backFlashlightResult
+ }
+ except Exception as error:
+ console.error(f"手电筒操作失败: {error}")
+ return {
+ "success": False,
+ "message": f"手电筒操作失败: {error}",
+ "state": params.get("state") if params else None
+ }
+
+
+async def adjust_volume(params):
+ try:
+ if not params or not params.get("action"):
+ raise Error("Volume action is required")
+ action = params["action"].lower()
+ if action not in ("up", "down", "mute"):
+ raise Error("Invalid action. Must be 'up', 'down', or 'mute'")
+ count = params.get("count", 1) if params else 1
+ if count is None:
+ count = 1
+ if not (isinstance(count, (int, float)) and 1 <= count <= 20):
+ raise Error("Count must be a number between 1 and 20")
+
+ console.log(f"调节音量: {'增加' if action == 'up' else '减小' if action == 'down' else '静音'}, 次数: {count}")
+
+ if action == "up":
+ keyCode = "KEYCODE_VOLUME_UP"
+ actionName = "增加"
+ elif action == "down":
+ keyCode = "KEYCODE_VOLUME_DOWN"
+ actionName = "减小"
+ else:
+ keyCode = "KEYCODE_VOLUME_MUTE"
+ actionName = "静音"
+ count = 1
+
+ results = []
+ for i in range(count):
+ result = await Tools.UI.pressKey(keyCode)
+ results.append(result)
+ console.log(f"第 {i + 1} 次按键结果: {json.dumps(result)}")
+ if i < count - 1:
+ await Tools.System.sleep(100)
+
+ return {
+ "success": True,
+ "message": f"音量{actionName}操作完成,共执行 {count} 次",
+ "action": action,
+ "key_code": keyCode,
+ "count": count,
+ "results": results
+ }
+ except Exception as error:
+ console.error(f"音量调节失败: {error}")
+ return {
+ "success": False,
+ "message": f"音量调节失败: {error}",
+ "action": params.get("action") if params else None,
+ "count": params.get("count", 1) if params else 1
+ }
+
+
+async def toggle_wifi(params):
+ try:
+ if not params or not params.get("state") or params["state"].lower() not in ("on", "off"):
+ raise Error("State is required and must be 'on' or 'off'")
+ state = params["state"].lower()
+ console.log(f"{'开启' if state == 'on' else '关闭'} Wi-Fi...")
+ command = "svc wifi enable" if state == "on" else "svc wifi disable"
+ result = await Tools.System.shell(command)
+ return {
+ "success": True,
+ "message": f"Wi-Fi 已{'开启' if state == 'on' else '关闭'}",
+ "state": state,
+ "raw_result": result
+ }
+ except Exception as error:
+ console.error(f"Wi-Fi 操作失败: {error}")
+ return {
+ "success": False,
+ "message": f"Wi-Fi 操作失败: {error}",
+ "state": params.get("state") if params else None
+ }
+
+
+async def take_screenshot(params):
+ try:
+ filePath = params.get("file_path") if params else None
+ screenshotDir = "/sdcard/DCIM/Screenshots" # Android-only path
+ await Tools.Files.mkdir(screenshotDir, True)
+ if not filePath:
+ timestamp = datetime.now().isoformat().replace(":", "-").replace(".", "-")
+ filePath = f"{screenshotDir}/screenshot_{timestamp}.png"
+ console.log(f"截取屏幕并保存到: {filePath}")
+ result = await Tools.System.shell(f"screencap -p {filePath}")
+ return {
+ "success": True,
+ "message": f"截图已保存到 {filePath}",
+ "file_path": filePath,
+ "raw_result": result
+ }
+ except Exception as error:
+ console.error(f"截图失败: {error}")
+ return {"success": False, "message": f"截图失败: {error}"}
+
+
+async def take_photo():
+ try:
+ console.log("打开相机应用...")
+ intent = Intent(IntentAction.ACTION_IMAGE_CAPTURE)
+ intent.addFlag(IntentFlag.ACTIVITY_NEW_TASK)
+ result = await intent.start()
+ return {"success": True, "message": "相机应用已打开", "raw_result": result}
+ except Exception as error:
+ console.error(f"打开相机失败: {error}")
+ return {"success": False, "message": f"打开相机失败: {error}"}
+
+
+async def toggle_dark_mode(params):
+ try:
+ if not params or not params.get("state"):
+ raise Error("Dark mode state is required")
+ state = params["state"].lower()
+ if state == "on":
+ shellArg = "yes"
+ stateName = "开启"
+ elif state == "off":
+ shellArg = "no"
+ stateName = "关闭"
+ elif state == "auto":
+ shellArg = "auto"
+ stateName = "自动"
+ else:
+ raise Error("Invalid state. Must be 'on', 'off', or 'auto'")
+ console.log(f"设置深夜模式为 {stateName}...")
+ command = f"cmd uimode night {shellArg}"
+ result = await Tools.System.shell(command)
+ return {
+ "success": True,
+ "message": f"深夜模式已设置为 {stateName}",
+ "state": state,
+ "raw_result": result
+ }
+ except Exception as error:
+ console.error(f"深夜模式操作失败: {error}")
+ return {
+ "success": False,
+ "message": f"深夜模式操作失败: {error}",
+ "state": params.get("state") if params else None
+ }
+
+
+async def main():
+ try:
+ results = {}
+ return {
+ "message": "日常生活功能测试完成",
+ "test_results": results,
+ "timestamp": datetime.now().isoformat(),
+ "summary": "测试了各种日常生活功能,包括天气搜索、拨号和短信测试。请查看各功能的测试结果。"
+ }
+ except Exception as error:
+ return {"success": False, "message": f"测试过程中发生错误: {error}"}
+
+
+async def daily_wrap(func, params, successMessage, failMessage, additionalInfo=""):
+ try:
+ funcName = getattr(func, "__name__", "匿名函数")
+ console.log(f"开始执行函数: {funcName}")
+ console.log(f"参数: {json.dumps(params, indent=2, ensure_ascii=False, default=str)}")
+ result = await func(params)
+ console.log(f"函数 {funcName} 执行结果: {json.dumps(result, indent=2, ensure_ascii=False, default=str)}")
+ if result is None:
+ return
+ if isinstance(result, bool):
+ complete({
+ "success": result,
+ "message": successMessage if result else failMessage,
+ "additionalInfo": additionalInfo
+ })
+ else:
+ complete({
+ "success": True,
+ "message": successMessage,
+ "additionalInfo": additionalInfo,
+ "data": result
+ })
+ except Exception as error:
+ import traceback
+ console.error(f"函数执行失败!")
+ console.error(f"错误信息: {error}")
+ console.error(f"错误堆栈: {traceback.format_exc()}")
+ complete({
+ "success": False,
+ "message": f"{failMessage}: {error}",
+ "additionalInfo": additionalInfo,
+ "error_stack": traceback.format_exc()
+ })
+
+
+# 导出
+async def _export_get_current_date(params):
+ await daily_wrap(get_current_date, params, "获取日期时间成功", "获取日期时间失败")
+
+async def _export_device_status(params):
+ await daily_wrap(device_status, params, "获取设备状态成功", "获取设备状态失败")
+
+async def _export_search_weather(params):
+ await daily_wrap(search_weather, params, "获取天气信息成功", "获取天气信息失败")
+
+async def _export_set_reminder(params):
+ await daily_wrap(set_reminder, params, "设置提醒成功", "设置提醒失败")
+
+async def _export_schedule_one_time_task(params):
+ await daily_wrap(schedule_one_time_task, params, "一次性定时任务创建成功", "一次性定时任务创建失败")
+
+async def _export_set_alarm(params):
+ await daily_wrap(set_alarm, params, "设置闹钟成功", "设置闹钟失败")
+
+async def _export_send_message(params):
+ await daily_wrap(send_message, params, "发送短信成功", "发送短信失败")
+
+async def _export_wechat_send_message(params):
+ await daily_wrap(wechat_send_message, params, "调用微信发送消息成功", "调用微信发送消息失败")
+
+async def _export_qq_send_message(params):
+ await daily_wrap(qq_send_message, params, "调用 QQ 发送消息成功", "调用 QQ 发送消息失败")
+
+async def _export_wechat_post_moments(params):
+ await daily_wrap(wechat_post_moments, params, "调用微信朋友圈成功", "调用微信朋友圈失败")
+
+async def _export_make_phone_call(params):
+ await daily_wrap(make_phone_call, params, "拨打电话成功", "拨打电话失败")
+
+async def _export_toggle_flashlight(params):
+ await daily_wrap(toggle_flashlight, params, "手电筒操作成功", "手电筒操作失败")
+
+async def _export_adjust_volume(params):
+ await daily_wrap(adjust_volume, params, "音量调节成功", "音量调节失败")
+
+async def _export_toggle_wifi(params):
+ await daily_wrap(toggle_wifi, params, "Wi-Fi 操作成功", "Wi-Fi 操作失败")
+
+async def _export_take_screenshot(params):
+ await daily_wrap(take_screenshot, params, "截图成功", "截图失败")
+
+async def _export_take_photo(params):
+ await daily_wrap(take_photo, params, "打开相机成功", "打开相机失败")
+
+async def _export_toggle_dark_mode(params):
+ await daily_wrap(toggle_dark_mode, params, "深夜模式操作成功", "深夜模式操作失败")
+
+async def _export_main(params):
+ await daily_wrap(main, params, "主函数执行成功", "主函数执行失败")
+
+exports.get_current_date = _export_get_current_date
+exports.device_status = _export_device_status
+exports.search_weather = _export_search_weather
+exports.set_reminder = _export_set_reminder
+exports.schedule_one_time_task = _export_schedule_one_time_task
+exports.set_alarm = _export_set_alarm
+exports.send_message = _export_send_message
+exports.wechat_send_message = _export_wechat_send_message
+exports.qq_send_message = _export_qq_send_message
+exports.wechat_post_moments = _export_wechat_post_moments
+exports.make_phone_call = _export_make_phone_call
+exports.toggle_flashlight = _export_toggle_flashlight
+exports.adjust_volume = _export_adjust_volume
+exports.main = _export_main
+exports.toggle_wifi = _export_toggle_wifi
+exports.take_screenshot = _export_take_screenshot
+exports.take_photo = _export_take_photo
+exports.toggle_dark_mode = _export_toggle_dark_mode
diff --git a/operit_py/douyin_download.py b/operit_py/douyin_download.py
new file mode 100644
index 0000000..e5116e2
--- /dev/null
+++ b/operit_py/douyin_download.py
@@ -0,0 +1,285 @@
+# METADATA
+# {
+# "name": "douyin_download",
+#
+# "display_name": {
+# "zh": "抖音下载工具",
+# "en": "Douyin Download Tool"
+# },
+# "description": { "zh": "抖音工具包,提供从分享链接或分享口令中提取并下载无水印视频的功能。", "en": "Douyin toolkit for extracting and downloading watermark-free videos from share links or share codes." },
+# "enabledByDefault": true,
+# "category": "Media",
+# "tools": [
+# {
+# "name": "get_douyin_download_link",
+# "description": { "zh": "解析抖音分享链接或口令,下载无水印视频到本地", "en": "Parse a Douyin share link/code and download a watermark-free video to local storage." },
+# "parameters": [
+# {
+# "name": "input",
+# "description": { "zh": "抖音分享链接或包含链接的分享口令文本", "en": "Douyin share link, or share-code text that contains a link." },
+# "type": "string",
+# "required": true
+# }
+# ]
+# },
+# {
+# "name": "get_douyin_video_info",
+# "description": { "zh": "解析抖音分享链接或口令,仅获取视频信息和无水印下载链接,不下载视频", "en": "Parse a Douyin share link/code and return video info + watermark-free download URL (without downloading)." },
+# "parameters": [
+# {
+# "name": "input",
+# "description": { "zh": "抖音分享链接或包含链接的分享口令文本", "en": "Douyin share link, or share-code text that contains a link." },
+# "type": "string",
+# "required": true
+# }
+# ]
+# }
+# ]
+# }
+
+import re
+import json
+import traceback
+
+client = OkHttp.newClient()
+
+# 抖音相关URL模式
+DOUYIN_URL_PATTERNS = [
+ re.compile(r'https?://v\.douyin\.com/[^\s]+'),
+ re.compile(r'https?://www\.douyin\.com/video/[0-9]+'),
+ re.compile(r'https?://www\.douyin\.com/share/video/[0-9]+')
+]
+
+
+async def douyin_wrap(func, params, successMessage, failMessage):
+ """包装函数调用,提供标准化的成功/错误处理"""
+ try:
+ console.log(f"开始执行函数: {func.__name__ or '匿名函数'}")
+ result = await func(params)
+ complete({"success": True, "message": successMessage, "data": result})
+ except Exception as error:
+ msg = str(error) if isinstance(error, Exception) else str(error)
+ console.error(f"函数 {func.__name__ or '匿名函数'} 执行失败! 错误: {msg}")
+ stack = traceback.format_exc() if isinstance(error, Exception) else None
+ complete({"success": False, "message": f"{failMessage}: {msg}", "error_stack": stack})
+
+
+def extractDouyinUrl(text):
+ """从文本中提取抖音链接"""
+ for pattern in DOUYIN_URL_PATTERNS:
+ match = pattern.search(text)
+ if match:
+ return match.group(0)
+ return None
+
+
+async def resolveDouyinUrl(shareUrl):
+ """解析抖音分享链接,获取视频信息"""
+ try:
+ console.log(f"正在解析抖音链接: {shareUrl}")
+
+ request = client.newRequest()
+ request = request.url(shareUrl)
+ request = request.method("GET")
+ request = request.headers({
+ "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/121.0.2277.107 Version/17.0 Mobile/15E148 Safari/604.1",
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
+ "Connection": "keep-alive"
+ })
+
+ response = await request.build().execute()
+
+ if not response.isSuccessful():
+ raise Exception(f"链接解析失败 ({response.statusCode})")
+
+ html = response.content
+
+ # 尝试从ROUTER_DATA中提取视频信息
+ routerDataMatch = re.search(r'window\._ROUTER_DATA\s*=\s*({.*?})', html, re.DOTALL)
+ if routerDataMatch and routerDataMatch.group(1):
+ try:
+ routerData = json.loads(routerDataMatch.group(1))
+ # The actual key might vary, let's find it dynamically
+ loaderData = routerData.get("loaderData", {}) if isinstance(routerData, dict) else {}
+ pageDataKey = next((k for k in loaderData.keys() if "/page" in k), None)
+ if not pageDataKey:
+ raise Exception("无法在 _ROUTER_DATA 中找到页面数据")
+ pageData = loaderData.get(pageDataKey) or {}
+ videoInfoRes = pageData.get("videoInfoRes") or {}
+ item_list = videoInfoRes.get("item_list") or []
+ videoData = item_list[0] if len(item_list) > 0 else None
+
+ if videoData:
+ videoId = videoData.get("aweme_id")
+ videoTitle = videoData.get("desc")
+ video = videoData.get("video") or {}
+ play_addr = video.get("play_addr") or {}
+ url_list = play_addr.get("url_list") or []
+ watermarkedUrl = url_list[0] if len(url_list) > 0 else None
+
+ if videoId and videoTitle and watermarkedUrl:
+ console.log(f"通过 _ROUTER_DATA 找到视频信息: ID={videoId}")
+ downloadUrl = watermarkedUrl.replace("playwm", "play")
+ return {"videoId": videoId, "downloadUrl": downloadUrl, "videoTitle": videoTitle}
+ except Exception as e:
+ msg = str(e) if isinstance(e, Exception) else str(e)
+ console.error(f"解析 _ROUTER_DATA JSON 失败: {msg}")
+
+ raise Exception("无法从HTML中解析出 _ROUTER_DATA")
+
+ except Exception as error:
+ msg = str(error) if isinstance(error, Exception) else str(error)
+ console.error(f"解析抖音链接时出错: {msg}")
+ raise error
+
+
+async def get_douyin_download_link(params):
+ """获取抖音视频的无水印下载链接并下载视频"""
+ input_ = params.get("input")
+
+ if not input_:
+ raise Exception("输入内容不能为空")
+
+ console.log("开始获取并下载抖音无水印视频...")
+
+ try:
+ # 1. 提取和解析URL
+ douyinUrl = extractDouyinUrl(input_)
+ if not douyinUrl:
+ raise Exception("未找到有效的抖音链接")
+ console.log(f"找到抖音链接: {douyinUrl}")
+
+ # 2. 解析链接并获取所有需要的视频信息
+ console.log("正在解析分享链接...")
+ resolved = await resolveDouyinUrl(douyinUrl)
+ videoId = resolved["videoId"]
+ downloadUrl = resolved["downloadUrl"]
+ rawVideoTitle = resolved["videoTitle"]
+ console.log(f"解析到视频ID: {videoId}")
+ console.log(f"获取到无水印下载链接: {downloadUrl}")
+
+ # 获取视频标题(用于文件命名)
+ videoTitle = (rawVideoTitle or f"douyin_{videoId}").strip()
+ # 替换文件名中的非法字符
+ videoTitle = re.sub(r'[\\/:*?"<>|]', '_', videoTitle)
+
+ # 逐级创建目录并下载视频
+ baseDir = getPluginConfigDir("douyin_download")
+ destinationDir = f"{baseDir}/downloads"
+ destinationPath = f"{destinationDir}/{videoTitle}_{videoId}.mp4"
+
+ console.log(f"确保目录存在: {destinationDir}")
+ # 逐级创建目录以确保路径存在
+ dirsToCreate = [baseDir, destinationDir]
+ for dir_ in dirsToCreate:
+ mkdirResult = await Tools.Files.mkdir(dir_)
+ if not mkdirResult.get("successful"):
+ # 忽略"目录已存在"的错误,但记录其他可能的错误
+ console.warn(f"创建目录 '{dir_}' 失败 (可能已存在): {mkdirResult.get('details')}")
+
+ console.log(f"开始下载视频到: {destinationPath}")
+ downloadResult = await Tools.Files.download(downloadUrl, destinationPath)
+
+ if not downloadResult.get("successful"):
+ raise Exception(f"视频下载失败: {downloadResult.get('details')}")
+
+ successMessage = f'视频"{videoTitle}"成功下载到: {destinationPath}'
+ console.log(successMessage)
+ return successMessage
+
+ except Exception as error:
+ message = str(error) if isinstance(error, Exception) else str(error)
+ console.error(f"处理失败: {message}")
+ # 重新抛出错误,以便包装器可以捕获它
+ raise Exception(message)
+
+
+async def get_douyin_video_info(params):
+ """获取抖音视频信息和无水印下载链接(不下载视频)"""
+ input_ = params.get("input")
+
+ if not input_:
+ raise Exception("输入内容不能为空")
+
+ console.log("开始解析抖音视频信息...")
+
+ try:
+ # 1. 提取URL
+ douyinUrl = extractDouyinUrl(input_)
+ if not douyinUrl:
+ raise Exception("未找到有效的抖音链接")
+ console.log(f"找到抖音链接: {douyinUrl}")
+
+ # 2. 解析链接并获取视频信息
+ console.log("正在解析分享链接...")
+ resolved = await resolveDouyinUrl(douyinUrl)
+ videoId = resolved["videoId"]
+ downloadUrl = resolved["downloadUrl"]
+ videoTitle = resolved["videoTitle"]
+ console.log(f"解析到视频ID: {videoId}")
+ console.log(f"获取到无水印下载链接: {downloadUrl}")
+ console.log(f"视频标题: {videoTitle}")
+
+ result = {
+ "videoId": videoId,
+ "videoTitle": videoTitle,
+ "downloadUrl": downloadUrl,
+ "originalUrl": douyinUrl
+ }
+
+ console.log("视频信息解析成功")
+ return result
+
+ except Exception as error:
+ message = str(error) if isinstance(error, Exception) else str(error)
+ console.error(f"解析失败: {message}")
+ raise Exception(message)
+
+
+async def main():
+ """抖音工具功能测试主函数"""
+ console.log("🚀 开始执行抖音工具功能测试...")
+ await test_download_link()
+ summary = "抖音下载工具测试完成!\n" + "✅ 下载功能已测试"
+ console.log(f"\n{summary}")
+ return summary
+
+
+async def test_download_link():
+ """测试下载功能"""
+ testUrl = "3.00 05/24 Z@M.JI TLW:/ 这里有几首melodic dubstep,看你认识几个 # 电子音乐 # 音乐分享 # 顶级旋律 # 热门音乐🔥百听不厌 # 戴上耳机 https://v.douyin.com/AT8AfEbuP_k/ 复制此链接,打开Dou音搜索,直接观看视频!" # 使用真实分享文本进行测试
+ console.log("1. 测试视频下载功能 (使用真实分享文本)")
+ # 我们预期这个调用会成功提取URL并尝试下载
+ try:
+ result = await get_douyin_download_link({"input": testUrl})
+ console.log(f"✅ 下载功能测试成功, 结果: {result}")
+ return result
+ except Exception as e:
+ errorMessage = str(e) if isinstance(e, Exception) else str(e)
+ console.error(f"❌ 下载功能测试失败: {errorMessage}")
+ # 在测试中,即使是预期的失败,也应该被视为一个需要注意的问题
+ raise e
+
+
+async def get_douyin_download_link_wrapper(params):
+ await douyin_wrap(get_douyin_download_link, params, "抖音视频下载完成", "抖音视频下载失败")
+
+
+async def get_douyin_video_info_wrapper(params):
+ await douyin_wrap(get_douyin_video_info, params, "抖音视频信息获取成功", "抖音视频信息获取失败")
+
+
+async def main_wrapper(params=None):
+ await douyin_wrap(main, params, "抖音工具测试完成", "抖音工具测试失败")
+
+
+async def test_download_link_wrapper(params=None):
+ await douyin_wrap(test_download_link, params, "下载链接获取测试成功", "下载链接获取测试失败")
+
+
+# 导出所有功能
+exports.get_douyin_download_link = get_douyin_download_link_wrapper
+exports.get_douyin_video_info = get_douyin_video_info_wrapper
+exports.main = main_wrapper
+exports.test_download_link = test_download_link_wrapper
diff --git a/operit_py/duckduckgo.py b/operit_py/duckduckgo.py
new file mode 100644
index 0000000..c181190
--- /dev/null
+++ b/operit_py/duckduckgo.py
@@ -0,0 +1,218 @@
+# METADATA
+# {
+# "name": "duckduckgo",
+#
+# "display_name": {
+# "zh": "DuckDuckGo 搜索",
+# "en": "DuckDuckGo Search"
+# },
+# "description": { "zh": "使用DuckDuckGo进行网络搜索和内容抓取。", "en": "Use DuckDuckGo for web search and content extraction." },
+# "category": "Search",
+# "tools": [
+# {
+# "name": "search",
+# "description": { "zh": "执行DuckDuckGo搜索并返回格式化的结果。", "en": "Run a DuckDuckGo search and return formatted results." },
+# "parameters": [
+# {
+# "name": "query",
+# "description": { "zh": "搜索查询字符串", "en": "Search query string." },
+# "type": "string",
+# "required": true
+# },
+# {
+# "name": "max_results",
+# "description": { "zh": "返回的最大结果数 (默认: 10)", "en": "Maximum number of results to return (default: 10)." },
+# "type": "string",
+# "required": false
+# }
+# ]
+# },
+# {
+# "name": "fetch_content",
+# "description": { "zh": "从网页URL抓取和解析内容。", "en": "Fetch and parse content from a webpage URL." },
+# "parameters": [
+# {
+# "name": "url",
+# "description": { "zh": "要抓取内容的网页URL", "en": "Webpage URL to fetch and parse." },
+# "type": "string",
+# "required": true
+# }
+# ]
+# }
+# ]
+# }
+
+import re
+import time
+import asyncio
+import traceback
+from urllib.parse import unquote
+
+client = OkHttp.newClient()
+BASE_URL = "https://html.duckduckgo.com/html/"
+
+
+class RateLimiter:
+ """A simple rate limiter to avoid sending too many requests."""
+
+ def __init__(self, requestsPerMinute=30):
+ self.requestsPerMinute = requestsPerMinute
+ self.requests = []
+
+ async def acquire(self):
+ now = time.time() * 1000
+ # Remove requests older than 1 minute (60000 ms)
+ self.requests = [reqTime for reqTime in self.requests if now - reqTime < 60000]
+
+ if len(self.requests) >= self.requestsPerMinute:
+ timeSinceFirstRequest = now - self.requests[0]
+ waitTime = 60000 - timeSinceFirstRequest
+ if waitTime > 0:
+ await asyncio.sleep(waitTime / 1000)
+
+ self.requests.append(time.time() * 1000)
+
+
+searchRateLimiter = RateLimiter(30)
+fetchRateLimiter = RateLimiter(20)
+
+
+def decodeHtmlEntities(text):
+ """Decodes basic HTML entities."""
+ text = text.replace("&", "&")
+ text = text.replace("<", "<")
+ text = text.replace(">", ">")
+ text = text.replace(""", '"')
+ text = text.replace("'", "'")
+ return text
+
+
+async def search(params):
+ query = params.get("query")
+ max_results = 10
+ if params.get("max_results"):
+ try:
+ parsedMaxResults = int(params["max_results"])
+ max_results = parsedMaxResults
+ except (ValueError, TypeError):
+ pass
+
+ if not query:
+ raise Exception("查询不能为空")
+
+ await searchRateLimiter.acquire()
+ console.log(f"正在从DuckDuckGo搜索: {query}")
+
+ request = client.newRequest()
+ request = request.url(BASE_URL)
+ request = request.method("POST")
+ request = request.body({"q": query, "b": "", "kl": ""}, "form")
+ request = request.headers({
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
+ })
+
+ response = await request.build().execute()
+ if not response.isSuccessful():
+ raise Exception(f"HTTP 错误! 状态码: {response.statusCode}")
+
+ html = response.content
+ results = []
+ resultRegex = re.compile(
+ r'.*?(.*?).*?
.*?(.*?)',
+ re.DOTALL
+ )
+
+ for match in resultRegex.finditer(html):
+ if len(results) >= max_results:
+ break
+
+ link = match.group(1)
+ if "y.js" in link:
+ continue # Skip ads
+
+ if link.startswith("//duckduckgo.com/l/?uddg="):
+ try:
+ link = unquote(link.split("uddg=")[1].split("&")[0])
+ except Exception as e:
+ console.error(f"URL 解码失败: {link}")
+
+ title = decodeHtmlEntities(re.sub(r'<[^>]*>', '', match.group(2)).strip())
+ snippet = decodeHtmlEntities(re.sub(r'<[^>]*>', '', match.group(3)).strip())
+
+ results.append({"title": title, "link": link, "snippet": snippet, "position": len(results) + 1})
+
+ console.log(f"成功找到 {len(results)} 个结果")
+ return format_results_for_llm(results)
+
+
+async def fetch_content(params):
+ url = params.get("url")
+ if not url:
+ raise Exception("URL不能为空")
+
+ await fetchRateLimiter.acquire()
+ console.log(f"正在抓取内容: {url}")
+
+ try:
+ request = client.newRequest()
+ request = request.url(url)
+ request = request.method("GET")
+ request = request.headers({"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"})
+
+ response = await request.build().execute()
+ if not response.isSuccessful():
+ raise Exception(f"无法访问网页 ({response.statusCode})")
+
+ text = response.content
+ text = re.sub(r'', '', text, flags=re.IGNORECASE | re.DOTALL)
+ text = re.sub(r'', '', text, flags=re.IGNORECASE | re.DOTALL)
+ text = re.sub(r'', '', text, flags=re.IGNORECASE | re.DOTALL)
+ text = re.sub(r'', '', text, flags=re.IGNORECASE | re.DOTALL)
+ text = re.sub(r'', '', text, flags=re.IGNORECASE | re.DOTALL)
+ text = re.sub(r'<[^>]+>', ' ', text)
+ text = re.sub(r'\s+', ' ', text).strip()
+
+ if len(text) > 8000:
+ text = text[:8000] + "... [内容被截断]"
+
+ console.log(f"成功抓取并解析内容 ({len(text)} 字符)")
+ return text
+ except Exception as error:
+ msg = str(getattr(error, "message", error))
+ console.error(f"从 {url} 抓取内容时出错: {msg}")
+ return f"错误: 从网页抓取内容时发生意外错误 ({msg})"
+
+
+def format_results_for_llm(results):
+ if not results or len(results) == 0:
+ return "没有为您的搜索查询找到结果。这可能是由于DuckDuckGo的机器人检测或查询没有匹配项。请尝试重新措辞您的搜索或在几分钟后重试。"
+
+ output = [
+ f"{r['position']}. {r['title']}\n URL: {r['link']}\n 摘要: {r['snippet']}"
+ for r in results
+ ]
+
+ return f"找到 {len(results)} 个搜索结果:\n\n" + "\n\n".join(output)
+
+
+async def duckduckgo_wrap(func, params, successMessage, failMessage):
+ try:
+ console.log(f"开始执行函数: {func.__name__ or '匿名函数'}")
+ result = await func(params)
+ complete({"success": True, "message": successMessage, "data": result})
+ except Exception as error:
+ msg = str(getattr(error, "message", error))
+ console.error(f"函数 {func.__name__ or '匿名函数'} 执行失败! 错误: {msg}")
+ complete({"success": False, "message": f"{failMessage}: {msg}", "error_stack": traceback.format_exc()})
+
+
+async def search_wrapper(params):
+ await duckduckgo_wrap(search, params, "搜索完成", "搜索失败")
+
+
+async def fetch_content_wrapper(params):
+ await duckduckgo_wrap(fetch_content, params, "内容抓取完成", "内容抓取失败")
+
+
+exports.search = search_wrapper
+exports.fetch_content = fetch_content_wrapper
diff --git a/operit_py/extended_chat.py b/operit_py/extended_chat.py
new file mode 100644
index 0000000..5565623
--- /dev/null
+++ b/operit_py/extended_chat.py
@@ -0,0 +1,505 @@
+# METADATA
+# {
+# "name": "extended_chat",
+# "display_name": {
+# "zh": "增强对话",
+# "en": "Extended Chat"
+# },
+# "description": {
+# "zh": "对话工具包:列出/查找/重命名/删除对话、跨话题读取消息、绑定角色卡对话并发送消息。",
+# "en": "Chat toolkit: list/find/rename/delete chats, read messages across chats, bind character cards and send messages."
+# },
+# "enabledByDefault": true,
+# "category": "Chat",
+# "tools": [
+# {
+# "name": "list_chats",
+# "description": { "zh": "列出并筛选对话(用于获取 chat_id)。", "en": "List and filter chats (to discover chat_id)." },
+# "parameters": [
+# { "name": "query", "description": { "zh": "可选:标题筛选关键字", "en": "Optional title keyword" }, "type": "string", "required": false },
+# { "name": "match", "description": { "zh": "可选:contains/exact/regex(默认 contains)", "en": "Optional: contains/exact/regex (default contains)" }, "type": "string", "required": false },
+# { "name": "limit", "description": { "zh": "可选:最多返回条数(默认 50)", "en": "Optional max results (default 50)" }, "type": "number", "required": false },
+# { "name": "sort_by", "description": { "zh": "可选:updatedAt/createdAt/messageCount(默认 updatedAt)", "en": "Optional: updatedAt/createdAt/messageCount (default updatedAt)" }, "type": "string", "required": false },
+# { "name": "sort_order", "description": { "zh": "可选:asc/desc(默认 desc)", "en": "Optional: asc/desc (default desc)" }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "find_chat",
+# "description": { "zh": "按标题查找一个对话并返回 chat_id。", "en": "Find a single chat by title and return chat_id." },
+# "parameters": [
+# { "name": "query", "description": { "zh": "标题关键字/正则", "en": "Title keyword/regex" }, "type": "string", "required": true },
+# { "name": "match", "description": { "zh": "可选:contains/exact/regex(默认 contains)", "en": "Optional: contains/exact/regex (default contains)" }, "type": "string", "required": false },
+# { "name": "index", "description": { "zh": "可选:当匹配多个时选择第 N 个(默认 0)", "en": "Optional: pick Nth when multiple matches (default 0)" }, "type": "number", "required": false }
+# ]
+# },
+# {
+# "name": "read_messages",
+# "description": { "zh": "读取指定对话的消息(可按 chat_id 或 chat_title 指定)。", "en": "Read messages from a chat (by chat_id or chat_title)." },
+# "parameters": [
+# { "name": "chat_id", "description": { "zh": "目标对话 ID(可选)", "en": "Target chat id (optional)" }, "type": "string", "required": false },
+# { "name": "chat_title", "description": { "zh": "目标对话标题(可选;当 chat_id 为空时使用)", "en": "Target chat title (optional; used when chat_id is empty)" }, "type": "string", "required": false },
+# { "name": "chat_query", "description": { "zh": "可选:标题筛选关键字(当 chat_id/chat_title 为空时使用)", "en": "Optional title keyword (used when chat_id/chat_title is empty)" }, "type": "string", "required": false },
+# { "name": "chat_index", "description": { "zh": "可选:当筛选结果有多个时选择第 N 个(默认 0)", "en": "Optional: pick Nth when multiple matches (default 0)" }, "type": "number", "required": false },
+# { "name": "match", "description": { "zh": "可选:contains/exact/regex(默认 contains)", "en": "Optional: contains/exact/regex (default contains)" }, "type": "string", "required": false },
+# { "name": "order", "description": { "zh": "可选:asc/desc(默认 desc)", "en": "Optional: asc/desc (default desc)" }, "type": "string", "required": false },
+# { "name": "limit", "description": { "zh": "可选:返回消息条数(默认 20)", "en": "Optional: max number of messages (default 20)" }, "type": "number", "required": false }
+# ]
+# },
+# {
+# "name": "rename_chat",
+# "description": { "zh": "重命名指定对话(可按 chat_id 或 chat_title 指定)。", "en": "Rename a chat (by chat_id or chat_title)." },
+# "parameters": [
+# { "name": "new_title", "description": { "zh": "新的对话标题", "en": "New chat title" }, "type": "string", "required": true },
+# { "name": "chat_id", "description": { "zh": "目标对话 ID(可选)", "en": "Target chat id (optional)" }, "type": "string", "required": false },
+# { "name": "chat_title", "description": { "zh": "目标对话标题(可选;当 chat_id 为空时使用)", "en": "Target chat title (optional; used when chat_id is empty)" }, "type": "string", "required": false },
+# { "name": "chat_query", "description": { "zh": "可选:标题筛选关键字(当 chat_id/chat_title 为空时使用)", "en": "Optional title keyword (used when chat_id/chat_title is empty)" }, "type": "string", "required": false },
+# { "name": "chat_index", "description": { "zh": "可选:当筛选结果有多个时选择第 N 个(默认 0)", "en": "Optional: pick Nth when multiple matches (default 0)" }, "type": "number", "required": false },
+# { "name": "match", "description": { "zh": "可选:contains/exact/regex(默认 contains)", "en": "Optional: contains/exact/regex (default contains)" }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "delete_chat",
+# "description": { "zh": "删除指定对话(可按 chat_id 或 chat_title 指定)。", "en": "Delete a chat (by chat_id or chat_title)." },
+# "parameters": [
+# { "name": "chat_id", "description": { "zh": "目标对话 ID(可选)", "en": "Target chat id (optional)" }, "type": "string", "required": false },
+# { "name": "chat_title", "description": { "zh": "目标对话标题(可选;当 chat_id 为空时使用)", "en": "Target chat title (optional; used when chat_id is empty)" }, "type": "string", "required": false },
+# { "name": "chat_query", "description": { "zh": "可选:标题筛选关键字(当 chat_id/chat_title 为空时使用)", "en": "Optional title keyword (used when chat_id/chat_title is empty)" }, "type": "string", "required": false },
+# { "name": "chat_index", "description": { "zh": "可选:当筛选结果有多个时选择第 N 个(默认 0)", "en": "Optional: pick Nth when multiple matches (default 0)" }, "type": "number", "required": false },
+# { "name": "match", "description": { "zh": "可选:contains/exact/regex(默认 contains)", "en": "Optional: contains/exact/regex (default contains)" }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "chat_with_agent",
+# "description": { "zh": "与对应角色的 agent 对话:传入角色卡名称;chat_id 为空时自动创建新对话并返回新 ID。严格执行一角色一会话,不能多个角色共用同一会话。该工具可用于将任务分担给其他 agent 或与其他角色交流,但通常只有在用户明确表达此意图时使用;多数情况下,能自行完成的任务应优先直接完成。", "en": "Chat with the agent for the specified character card name; if chat_id is empty, create a new chat and return its ID. Enforces one role per chat (no sharing between roles). Use this tool to delegate tasks to other agents or communicate with other roles when the user explicitly intends it; otherwise, prefer completing tasks directly without using this tool." },
+# "parameters": [
+# { "name": "message", "description": { "zh": "发送给 AI 的内容", "en": "Message to send to AI" }, "type": "string", "required": true },
+# { "name": "character_card_name", "description": { "zh": "角色卡名称", "en": "Character card name" }, "type": "string", "required": true },
+# { "name": "chat_id", "description": { "zh": "目标对话 ID(可选;为空时新建)", "en": "Target chat id (optional; create new if empty)" }, "type": "string", "required": false },
+# { "name": "timeout", "description": { "zh": "可选:等待返回的超时秒数(默认 180)", "en": "Optional timeout seconds to wait for response (default 180)" }, "type": "number", "required": false },
+# { "name": "persist_turn", "description": { "zh": "可选:是否持久化本轮用户消息和 AI 回复(默认 true)", "en": "Optional: whether to persist this turn's user message and AI reply (default true)" }, "type": "boolean", "required": false },
+# { "name": "notify_reply", "description": { "zh": "可选:是否覆盖本轮回复通知开关", "en": "Optional: override reply notification for this turn" }, "type": "boolean", "required": false },
+# { "name": "hide_user_message", "description": { "zh": "可选:是否在 UI 中隐藏用户消息正文并显示占位标记", "en": "Optional: hide the user message body in UI and show a placeholder marker" }, "type": "boolean", "required": false },
+# { "name": "disable_warning", "description": { "zh": "可选:是否关闭本轮 AI 生成的 warning 标记", "en": "Optional: suppress AI-generated warning markup for this turn" }, "type": "boolean", "required": false }
+# ]
+# },
+# {
+# "name": "agent_status",
+# "description": { "zh": "查询对话的输入处理状态。", "en": "Check a chat's input processing status." },
+# "parameters": [
+# { "name": "chat_id", "description": { "zh": "目标对话 ID", "en": "Target chat id" }, "type": "string", "required": true }
+# ]
+# },
+# {
+# "name": "list_character_cards",
+# "description": { "zh": "列出所有角色卡(用于获取 character_card_id)。", "en": "List all character cards (to discover character_card_id)." },
+# "parameters": []
+# }
+# ]
+# }
+
+import asyncio
+import math
+
+
+def _to_number(value, default=None):
+ if value is None:
+ return default
+ try:
+ n = float(value)
+ if math.isnan(n):
+ return default
+ return n
+ except (TypeError, ValueError):
+ return default
+
+
+def normalizeMatchMode(match=None):
+ m = str(match or "").strip().lower()
+ if m in ("exact", "regex", "contains"):
+ return m
+ return "contains"
+
+
+async def list_chats_impl(params):
+ params = params or {}
+ query = str(params.get("query") if params.get("query") is not None else "").strip()
+ matchMode = normalizeMatchMode(params.get("match"))
+ limit = _to_number(params.get("limit"), None)
+ sortBy = str(params.get("sort_by")).strip() if params.get("sort_by") else None
+ sortOrder = str(params.get("sort_order")).strip().lower() if params.get("sort_order") else None
+
+ listParams = {}
+ if query:
+ listParams["query"] = query
+ if matchMode:
+ listParams["match"] = matchMode
+ if limit is not None:
+ listParams["limit"] = limit
+ if sortBy:
+ listParams["sort_by"] = sortBy
+ if sortOrder:
+ listParams["sort_order"] = sortOrder
+
+ listResult = await Tools.Chat.listChats(listParams)
+ chats = listResult.get("chats") if isinstance(listResult, dict) else listResult
+ if chats is None:
+ chats = []
+ return {
+ "success": True,
+ "message": "对话列表获取完成",
+ "data": {
+ "totalCount": (listResult.get("totalCount") if isinstance(listResult, dict) else None) or len(chats),
+ "currentChatId": (listResult.get("currentChatId") if isinstance(listResult, dict) else None) or None,
+ "matchedCount": (listResult.get("totalCount") if isinstance(listResult, dict) else None) or len(chats),
+ "chats": chats,
+ }
+ }
+
+
+async def find_chat_impl(params):
+ params = params or {}
+ query = str(params.get("query") if params.get("query") is not None else "").strip()
+ if not query:
+ raise Exception("Missing parameter: query")
+
+ matchMode = normalizeMatchMode(params.get("match"))
+ index = _to_number(params.get("index"), 0)
+ if index is None:
+ index = 0
+ findParams = {"query": query}
+ if matchMode:
+ findParams["match"] = matchMode
+ findParams["index"] = index
+ findResult = await Tools.Chat.findChat(findParams)
+ picked = (findResult.get("chat") if isinstance(findResult, dict) else None) or None
+ if not picked:
+ raise Exception(f"Chat not found by query: {query}")
+
+ return {
+ "success": True,
+ "message": "对话查找完成",
+ "data": {
+ "chat": picked,
+ "matchedCount": (findResult.get("matchedCount") if isinstance(findResult, dict) else None) or 1,
+ }
+ }
+
+
+async def resolveChatId(params):
+ params = params or {}
+ if isinstance(params.get("chat_id"), str) and params.get("chat_id").strip():
+ return params["chat_id"].strip()
+
+ title = params.get("chat_title").strip() if isinstance(params.get("chat_title"), str) else ""
+ query = params.get("chat_query").strip() if isinstance(params.get("chat_query"), str) else ""
+ matchMode = normalizeMatchMode(params.get("match"))
+ index = _to_number(params.get("chat_index"), 0)
+ if index is None:
+ index = 0
+
+ if not title and not query:
+ raise Exception("Missing parameter: chat_id or chat_title or chat_query is required")
+
+ needle = title or query
+ findParams = {"query": needle}
+ findParams["match"] = "exact" if title else matchMode
+ findParams["index"] = index
+ findResult = await Tools.Chat.findChat(findParams)
+ picked = (findResult.get("chat") if isinstance(findResult, dict) else None) or None
+ if not picked or not picked.get("id"):
+ raise Exception(f"Chat not found by query: {needle}")
+ return picked.get("id")
+
+
+async def read_messages_impl(params):
+ params = params or {}
+ chatId = await resolveChatId(params)
+
+ orderRaw = str(params.get("order")).strip().lower() if params.get("order") is not None else ""
+ order = orderRaw if orderRaw in ("asc", "desc") else "desc"
+
+ limit = _to_number(params.get("limit"), 20)
+ if limit is None:
+ limit = 20
+
+ result = await Tools.Chat.getMessages(chatId, {
+ "order": order,
+ "limit": limit,
+ })
+
+ rawMessages = result.get("messages") if isinstance(result, dict) else result
+ if rawMessages is None:
+ rawMessages = []
+
+ def _format(m):
+ role = str(m.get("roleName") if m.get("roleName") is not None else (m.get("sender") if m.get("sender") is not None else "")) or "message"
+ ts = str(m.get("timestamp")) if m.get("timestamp") is not None else ""
+ header = f"[{ts}] {role}" if ts else role
+ return f"{header}:\n{str(m.get('content') if m.get('content') is not None else '')}"
+
+ text = "\n\n".join(_format(m) for m in rawMessages)
+
+ return {
+ "success": True,
+ "message": "读取对话消息完成",
+ "data": {
+ "result": result,
+ "text": text,
+ },
+ }
+
+
+async def rename_chat_impl(params):
+ params = params or {}
+ newTitle = str(params.get("new_title") if params.get("new_title") is not None else "").strip()
+ if not newTitle:
+ raise Exception("Missing parameter: new_title")
+
+ chatId = await resolveChatId(params)
+ result = await Tools.Chat.updateTitle(chatId, newTitle)
+
+ return {
+ "success": True,
+ "message": "对话重命名完成",
+ "data": {
+ "chat_id": chatId,
+ "title": newTitle,
+ "result": result,
+ },
+ }
+
+
+async def delete_chat_impl(params):
+ params = params or {}
+ chatId = await resolveChatId(params)
+ result = await Tools.Chat.deleteChat(chatId)
+
+ return {
+ "success": True,
+ "message": "对话删除完成",
+ "data": {
+ "chat_id": chatId,
+ "result": result,
+ },
+ }
+
+
+async def agent_status_impl(params):
+ params = params or {}
+ chatId = str(params.get("chat_id") if params.get("chat_id") is not None else "").strip()
+ if not chatId:
+ raise Exception("Missing parameter: chat_id")
+ result = await Tools.Chat.agentStatus(chatId)
+ return {
+ "success": True,
+ "message": "对话状态查询完成",
+ "data": {
+ "result": result,
+ },
+ }
+
+
+async def list_character_cards_impl():
+ result = await Tools.Chat.listCharacterCards()
+ cards = result.get("cards") if isinstance(result, dict) else result
+ if cards is None:
+ cards = []
+ return {
+ "success": True,
+ "message": "角色卡列表获取完成",
+ "data": {
+ "totalCount": (result.get("totalCount") if isinstance(result, dict) else None) or len(cards),
+ "cards": cards,
+ },
+ }
+
+
+async def chat_with_agent_impl(params):
+ params = params or {}
+ message = str(params.get("message") if params.get("message") is not None else "")
+ characterCardNameInput = str(params.get("character_card_name") if params.get("character_card_name") is not None else "").strip()
+ if not message.strip():
+ raise Exception("Missing parameter: message")
+ if not characterCardNameInput:
+ raise Exception("Missing parameter: character_card_name")
+
+ characterCardName = characterCardNameInput
+ characterCardId = ""
+ try:
+ cardResult = await Tools.Chat.listCharacterCards()
+ cards = cardResult.get("cards") if isinstance(cardResult, dict) else cardResult
+ if cards is None:
+ cards = []
+ targetCard = next((card for card in cards if card.get("name") == characterCardNameInput), None)
+ if not targetCard:
+ raise Exception(f"Character card not found: {characterCardNameInput}")
+ characterCardName = targetCard.get("name")
+ characterCardId = targetCard.get("id")
+ except Exception:
+ if not characterCardId:
+ raise Exception(f"Character card not found: {characterCardNameInput}")
+
+ try:
+ await Tools.Chat.startService()
+ except Exception:
+ # ignore service start errors to avoid blocking agent message
+ pass
+
+ chatId = str(params.get("chat_id") if params.get("chat_id") is not None else "").strip()
+ if not chatId:
+ lang = str(getLang() or "").lower()
+ group = "子任务" if lang == "zh" else "subTask"
+ creation = await Tools.Chat.createNew(
+ group,
+ False,
+ characterCardId,
+ )
+ chatId = str(creation.get("chatId") if isinstance(creation, dict) and creation.get("chatId") is not None else "").strip()
+ if not chatId:
+ raise Exception("Failed to create new chat")
+ else:
+ findResult = await Tools.Chat.findChat({
+ "query": chatId,
+ "match": "exact",
+ "index": 0,
+ })
+ boundName = (findResult.get("chat") if isinstance(findResult, dict) else {}).get("characterCardName") if isinstance(findResult, dict) else None
+ if boundName and boundName != characterCardName:
+ raise Exception(f"Chat {chatId} 已绑定角色 {boundName},不能与 {characterCardName} 共用会话")
+
+ timeoutRaw = _to_number(params.get("timeout"), 180)
+ if timeoutRaw is None or timeoutRaw <= 0:
+ timeoutSec = 180
+ else:
+ timeoutSec = timeoutRaw
+ timeoutMs = timeoutSec * 1000
+
+ sendMessageOptions = {}
+ if params.get("persist_turn") is not None:
+ sendMessageOptions["persist_turn"] = params.get("persist_turn")
+ if params.get("notify_reply") is not None:
+ sendMessageOptions["notify_reply"] = params.get("notify_reply")
+ if params.get("hide_user_message") is not None:
+ sendMessageOptions["hide_user_message"] = params.get("hide_user_message")
+ if params.get("disable_warning") is not None:
+ sendMessageOptions["disable_warning"] = params.get("disable_warning")
+ sendMessageOptions["timeout_ms"] = timeoutMs
+
+ sendResult = None
+ timed_out = False
+ try:
+ sendResult = await asyncio.wait_for(
+ Tools.Chat.sendMessage(
+ message,
+ chatId,
+ characterCardId,
+ getCallerName() or characterCardName,
+ sendMessageOptions,
+ ),
+ timeout=timeoutMs / 1000
+ )
+ except asyncio.TimeoutError:
+ timed_out = True
+
+ if timed_out:
+ return {
+ "success": True,
+ "message": f"已发送给 {characterCardName},等待响应超时({timeoutSec}s)",
+ "data": {
+ "chat_id": chatId,
+ "timeout": True,
+ "hint": "可以通过 agent_status 查看该 agent 是否已处理你的问题。",
+ },
+ }
+
+ return {
+ "success": True,
+ "message": f"发消息给 {characterCardName}",
+ "data": {
+ "chat_id": chatId,
+ "result": sendResult,
+ },
+ }
+
+
+async def wrapToolExecution(func, params):
+ try:
+ result = await func(params)
+ complete(result)
+ except Exception as error:
+ message = str(error)
+ console.error(f"Tool {func.__name__} failed unexpectedly", error)
+ complete({
+ "success": False,
+ "message": f"读取对话消息失败: {message}",
+ })
+
+
+async def wrapToolExecutionNoParams(func):
+ try:
+ result = await func()
+ complete(result)
+ except Exception as error:
+ message = str(error)
+ console.error(f"Tool {func.__name__} failed unexpectedly", error)
+ complete({
+ "success": False,
+ "message": f"读取对话消息失败: {message}",
+ })
+
+
+async def read_messages(params):
+ return await wrapToolExecution(read_messages_impl, params)
+
+
+async def rename_chat(params):
+ return await wrapToolExecution(rename_chat_impl, params)
+
+
+async def delete_chat(params):
+ return await wrapToolExecution(delete_chat_impl, params)
+
+
+async def list_chats(params):
+ return await wrapToolExecution(list_chats_impl, params)
+
+
+async def find_chat(params):
+ return await wrapToolExecution(find_chat_impl, params)
+
+
+async def agent_status(params):
+ return await wrapToolExecution(agent_status_impl, params)
+
+
+async def list_character_cards(params=None):
+ if params is None:
+ params = {}
+ return await wrapToolExecutionNoParams(list_character_cards_impl)
+
+
+async def chat_with_agent(params):
+ return await wrapToolExecution(chat_with_agent_impl, params)
+
+
+async def main(params=None):
+ if params is None:
+ params = {}
+ complete({
+ "success": True,
+ "message": "extended_chat 工具包已加载",
+ "data": {
+ "hint": "Use extended_chat:read_messages / rename_chat / delete_chat.",
+ },
+ })
+
+
+exports.list_chats = list_chats
+exports.find_chat = find_chat
+exports.read_messages = read_messages
+exports.rename_chat = rename_chat
+exports.delete_chat = delete_chat
+exports.chat_with_agent = chat_with_agent
+exports.agent_status = agent_status
+exports.list_character_cards = list_character_cards
+exports.main = main
diff --git a/operit_py/extended_file_tools.py b/operit_py/extended_file_tools.py
new file mode 100644
index 0000000..b4ad7f1
--- /dev/null
+++ b/operit_py/extended_file_tools.py
@@ -0,0 +1,216 @@
+# METADATA
+# {
+# "name": "extended_file_tools",
+#
+# "display_name": {
+# "zh": "增强文件工具",
+# "en": "Extended File Tools"
+# },
+# "description": {
+# "zh": "拓展文件工具包:提供 file_exists / move_file / copy_file / file_info / unzip_files / zip_files / open_file / share_file(默认文件工具中已移除这些项)。",
+# "en": "Extended file tools: file_exists / move_file / copy_file / file_info / unzip_files / zip_files / open_file / share_file (removed from default file tools)."
+# },
+# "category": "File",
+# "enabledByDefault": true,
+# "tools": [
+# {
+# "name": "file_exists",
+# "description": { "zh": "检查文件或目录是否存在。", "en": "Check if a file or directory exists." },
+# "parameters": [
+# { "name": "path", "description": { "zh": "目标路径", "en": "Target path" }, "type": "string", "required": true },
+# { "name": "environment", "description": { "zh": "可选:android/linux", "en": "Optional: android/linux" }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "move_file",
+# "description": { "zh": "移动或重命名文件/目录。", "en": "Move or rename a file/directory." },
+# "parameters": [
+# { "name": "source", "description": { "zh": "源路径", "en": "Source path" }, "type": "string", "required": true },
+# { "name": "destination", "description": { "zh": "目标路径", "en": "Destination path" }, "type": "string", "required": true },
+# { "name": "environment", "description": { "zh": "可选:android/linux", "en": "Optional: android/linux" }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "copy_file",
+# "description": { "zh": "复制文件/目录(支持跨环境复制)。", "en": "Copy a file/directory (supports cross-environment copy)." },
+# "parameters": [
+# { "name": "source", "description": { "zh": "源路径", "en": "Source path" }, "type": "string", "required": true },
+# { "name": "destination", "description": { "zh": "目标路径", "en": "Destination path" }, "type": "string", "required": true },
+# { "name": "recursive", "description": { "zh": "可选:是否递归,默认 false", "en": "Optional: recursive (default: false)" }, "type": "boolean", "required": false },
+# { "name": "source_environment", "description": { "zh": "可选:源环境 android/linux", "en": "Optional: source environment android/linux" }, "type": "string", "required": false },
+# { "name": "dest_environment", "description": { "zh": "可选:目标环境 android/linux", "en": "Optional: destination environment android/linux" }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "file_info",
+# "description": { "zh": "获取文件/目录信息。", "en": "Get file/directory info." },
+# "parameters": [
+# { "name": "path", "description": { "zh": "目标路径", "en": "Target path" }, "type": "string", "required": true },
+# { "name": "environment", "description": { "zh": "可选:android/linux", "en": "Optional: android/linux" }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "zip_files",
+# "description": { "zh": "压缩文件/目录。", "en": "Zip files/directories." },
+# "parameters": [
+# { "name": "source", "description": { "zh": "源路径", "en": "Source path" }, "type": "string", "required": true },
+# { "name": "destination", "description": { "zh": "输出 zip 文件路径", "en": "Destination zip path" }, "type": "string", "required": true },
+# { "name": "environment", "description": { "zh": "可选:android/linux", "en": "Optional: android/linux" }, "type": "string", "required": false },
+# { "name": "include_root_directory", "description": { "zh": "压缩目录时是否保留源目录名作为顶层目录,默认 true", "en": "When zipping a directory, keep the source directory name as the top-level folder, default true" }, "type": "boolean", "required": false }
+# ]
+# },
+# {
+# "name": "unzip_files",
+# "description": { "zh": "解压 zip 文件。", "en": "Unzip an archive." },
+# "parameters": [
+# { "name": "source", "description": { "zh": "zip 文件路径", "en": "Zip file path" }, "type": "string", "required": true },
+# { "name": "destination", "description": { "zh": "解压目录", "en": "Destination directory" }, "type": "string", "required": true },
+# { "name": "environment", "description": { "zh": "可选:android/linux", "en": "Optional: android/linux" }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "open_file",
+# "description": { "zh": "用系统默认应用打开文件。", "en": "Open a file with system default app." },
+# "parameters": [
+# { "name": "path", "description": { "zh": "文件路径", "en": "File path" }, "type": "string", "required": true },
+# { "name": "environment", "description": { "zh": "可选:android/linux", "en": "Optional: android/linux" }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "share_file",
+# "description": { "zh": "分享文件给其他应用。", "en": "Share a file with other apps." },
+# "parameters": [
+# { "name": "path", "description": { "zh": "文件路径", "en": "File path" }, "type": "string", "required": true },
+# { "name": "title", "description": { "zh": "可选:分享标题", "en": "Optional: share title" }, "type": "string", "required": false },
+# { "name": "environment", "description": { "zh": "可选:android/linux", "en": "Optional: android/linux" }, "type": "string", "required": false }
+# ]
+# }
+# ]
+# }
+
+
+async def file_exists(params):
+ result = await Tools.Files.exists(params["path"], params.get("environment"))
+ exists_val = result.get("exists") if isinstance(result, dict) else None
+ success = bool(result) and (exists_val if exists_val is not None else True)
+ return {"success": success, "message": "检查完成", "data": result}
+
+
+async def move_file(params):
+ result = await Tools.Files.move(params["source"], params["destination"], params.get("environment"))
+ return {"success": bool(result), "message": "移动完成", "data": result}
+
+
+async def copy_file(params):
+ result = await Tools.Files.copy(
+ params["source"],
+ params["destination"],
+ params.get("recursive"),
+ params.get("source_environment"),
+ params.get("dest_environment")
+ )
+ return {"success": bool(result), "message": "复制完成", "data": result}
+
+
+async def file_info(params):
+ result = await Tools.Files.info(params["path"], params.get("environment"))
+ return {"success": bool(result), "message": "获取信息完成", "data": result}
+
+
+async def zip_files(params):
+ result = await Tools.Files.zip(
+ params["source"],
+ params["destination"],
+ params.get("environment"),
+ params.get("include_root_directory")
+ )
+ return {"success": bool(result), "message": "压缩完成", "data": result}
+
+
+async def unzip_files(params):
+ result = await Tools.Files.unzip(params["source"], params["destination"], params.get("environment"))
+ return {"success": bool(result), "message": "解压完成", "data": result}
+
+
+async def open_file(params):
+ result = await Tools.Files.open(params["path"], params.get("environment"))
+ return {"success": bool(result), "message": "打开文件完成", "data": result}
+
+
+async def share_file(params):
+ result = await Tools.Files.share(params["path"], params.get("title"), params.get("environment"))
+ return {"success": bool(result), "message": "分享文件完成", "data": result}
+
+
+async def wrapToolExecution(func, params):
+ try:
+ result = await func(params)
+ complete(result)
+ except Exception as error:
+ console.error(f"Tool {func.__name__} failed unexpectedly", error)
+ msg = getattr(error, "message", str(error))
+ complete({
+ "success": False,
+ "message": f"工具执行时发生意外错误: {msg}",
+ })
+
+
+async def main():
+ results = []
+ results.append({"tool": "file_exists", "result": {"success": None, "message": "未测试"}})
+ results.append({"tool": "move_file", "result": {"success": None, "message": "未测试(会移动/重命名文件)"}})
+ results.append({"tool": "copy_file", "result": {"success": None, "message": "未测试(会复制文件)"}})
+ results.append({"tool": "file_info", "result": {"success": None, "message": "未测试"}})
+ results.append({"tool": "zip_files", "result": {"success": None, "message": "未测试(会写入zip文件)"}})
+ results.append({"tool": "unzip_files", "result": {"success": None, "message": "未测试(会写入解压文件)"}})
+ results.append({"tool": "open_file", "result": {"success": None, "message": "未测试(会拉起系统应用)"}})
+ results.append({"tool": "share_file", "result": {"success": None, "message": "未测试(会弹出分享面板)"}})
+
+ complete({
+ "success": True,
+ "message": "拓展文件工具包加载完成(未执行破坏性测试)",
+ "data": {"results": results}
+ })
+
+
+async def file_exists_wrapper(params):
+ await wrapToolExecution(file_exists, params)
+
+
+async def move_file_wrapper(params):
+ await wrapToolExecution(move_file, params)
+
+
+async def copy_file_wrapper(params):
+ await wrapToolExecution(copy_file, params)
+
+
+async def file_info_wrapper(params):
+ await wrapToolExecution(file_info, params)
+
+
+async def zip_files_wrapper(params):
+ await wrapToolExecution(zip_files, params)
+
+
+async def unzip_files_wrapper(params):
+ await wrapToolExecution(unzip_files, params)
+
+
+async def open_file_wrapper(params):
+ await wrapToolExecution(open_file, params)
+
+
+async def share_file_wrapper(params):
+ await wrapToolExecution(share_file, params)
+
+
+exports.file_exists = file_exists_wrapper
+exports.move_file = move_file_wrapper
+exports.copy_file = copy_file_wrapper
+exports.file_info = file_info_wrapper
+exports.zip_files = zip_files_wrapper
+exports.unzip_files = unzip_files_wrapper
+exports.open_file = open_file_wrapper
+exports.share_file = share_file_wrapper
+exports.main = main
diff --git a/operit_py/extended_http_tools.py b/operit_py/extended_http_tools.py
new file mode 100644
index 0000000..fc182e5
--- /dev/null
+++ b/operit_py/extended_http_tools.py
@@ -0,0 +1,198 @@
+# METADATA
+# {
+# "name": "extended_http_tools",
+#
+# "display_name": {
+# "zh": "增强 HTTP 工具",
+# "en": "Extended HTTP Tools"
+# },
+# "description": {
+# "zh": "允许文件上传,以及 GET/POST 等网络直接访问操作。",
+# "en": "Allows file uploads and direct network access operations such as GET/POST."
+# },
+# "enabledByDefault": true,
+# "category": "Network",
+# "tools": [
+# {
+# "name": "http_request",
+# "description": { "zh": "发送 HTTP 请求。", "en": "Send an HTTP request." },
+# "parameters": [
+# { "name": "url", "description": { "zh": "请求 URL", "en": "Request URL" }, "type": "string", "required": true },
+# { "name": "method", "description": { "zh": "请求方法:GET/POST/PUT/DELETE", "en": "Method: GET/POST/PUT/DELETE" }, "type": "string", "required": true },
+# { "name": "headers", "description": { "zh": "可选:headers(JSON 对象字符串)", "en": "Optional: headers (JSON object string)" }, "type": "string", "required": false },
+# { "name": "body", "description": { "zh": "可选:请求体(字符串)", "en": "Optional: body (string)" }, "type": "string", "required": false },
+# { "name": "body_type", "description": { "zh": "可选:json/form/text/xml", "en": "Optional: json/form/text/xml" }, "type": "string", "required": false },
+# { "name": "ignore_ssl", "description": { "zh": "可选:是否忽略 HTTPS 证书校验(true/false)", "en": "Optional: ignore HTTPS certificate verification (true/false)" }, "type": "boolean", "required": false }
+# ]
+# },
+# {
+# "name": "multipart_request",
+# "description": { "zh": "上传文件(multipart)。", "en": "Upload files (multipart)." },
+# "parameters": [
+# { "name": "url", "description": { "zh": "请求 URL", "en": "Request URL" }, "type": "string", "required": true },
+# { "name": "method", "description": { "zh": "请求方法:POST/PUT", "en": "Method: POST/PUT" }, "type": "string", "required": true },
+# { "name": "headers", "description": { "zh": "可选:headers(JSON 对象字符串)", "en": "Optional: headers (JSON object string)" }, "type": "string", "required": false },
+# { "name": "form_data", "description": { "zh": "可选:form_data(字符串)", "en": "Optional: form_data (string)" }, "type": "string", "required": false },
+# { "name": "files", "description": { "zh": "可选:files(JSON 数组字符串)", "en": "Optional: files (JSON array string)" }, "type": "string", "required": false },
+# { "name": "ignore_ssl", "description": { "zh": "可选:是否忽略 HTTPS 证书校验(true/false)", "en": "Optional: ignore HTTPS certificate verification (true/false)" }, "type": "boolean", "required": false }
+# ]
+# },
+# {
+# "name": "manage_cookies",
+# "description": { "zh": "管理 Cookies。", "en": "Manage cookies." },
+# "parameters": [
+# { "name": "action", "description": { "zh": "操作:get/set/clear", "en": "Action: get/set/clear" }, "type": "string", "required": true },
+# { "name": "domain", "description": { "zh": "可选:域名", "en": "Optional: domain" }, "type": "string", "required": false },
+# { "name": "cookies", "description": { "zh": "可选:cookies(字符串)", "en": "Optional: cookies (string)" }, "type": "string", "required": false }
+# ]
+# }
+# ]
+# }
+
+import re
+import random
+import datetime
+
+MAX_INLINE_HTTP_RESPONSE_CHARS = 12000
+
+
+async def http_request(params):
+ toolParams = {
+ "url": params["url"],
+ "method": params["method"],
+ }
+ if params.get("headers") is not None:
+ toolParams["headers"] = params["headers"]
+ if params.get("body") is not None:
+ toolParams["body"] = params["body"]
+ if params.get("body_type") is not None:
+ toolParams["body_type"] = params["body_type"]
+ if params.get("ignore_ssl") is not None:
+ toolParams["ignore_ssl"] = params["ignore_ssl"]
+
+ result = await toolCall({"name": "http_request", "params": toolParams})
+ statusCode = result.get("statusCode")
+ success = statusCode is not None and statusCode >= 200 and statusCode < 400
+
+ contentStr = result.get("content") if isinstance(result.get("content"), str) else ""
+ if len(contentStr) > MAX_INLINE_HTTP_RESPONSE_CHARS:
+ await Tools.Files.mkdir(OPERIT_CLEAN_ON_EXIT_DIR, True)
+
+ timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
+ timestamp = re.sub(r'[:.]', '-', timestamp)
+ rand = int(random.random() * 1000000)
+
+ ext = "txt"
+ ct = result.get("contentType", "").lower() if isinstance(result.get("contentType"), str) else ""
+ if "json" in ct:
+ ext = "json"
+ elif "html" in ct:
+ ext = "html"
+ elif "xml" in ct:
+ ext = "xml"
+
+ filePath = f"{OPERIT_CLEAN_ON_EXIT_DIR}/http_response_{timestamp}_{rand}.{ext}"
+ await Tools.Files.write(filePath, contentStr, False)
+
+ resultMeta = {
+ "url": result.get("url"),
+ "statusCode": result.get("statusCode"),
+ "statusMessage": result.get("statusMessage"),
+ "headers": result.get("headers"),
+ "contentType": result.get("contentType"),
+ "size": result.get("size"),
+ "content": "(saved_to_file)",
+ }
+
+ return {
+ "success": success,
+ "message": f"HTTP 请求完成;响应内容过大,已保存到文件:{filePath}。请使用 read_file_part 读取指定行范围,或用 grep_code 在该文件中检索关键字。",
+ "data": {
+ "result": resultMeta,
+ "content_saved_to": filePath,
+ "operit_clean_on_exit_dir": OPERIT_CLEAN_ON_EXIT_DIR,
+ },
+ }
+
+ return {"success": success, "message": "HTTP 请求完成", "data": result}
+
+
+async def multipart_request(params):
+ toolParams = {
+ "url": params["url"],
+ "method": params["method"],
+ }
+ if params.get("headers") is not None:
+ toolParams["headers"] = params["headers"]
+ if params.get("form_data") is not None:
+ toolParams["form_data"] = params["form_data"]
+ if params.get("files") is not None:
+ toolParams["files"] = params["files"]
+ if params.get("ignore_ssl") is not None:
+ toolParams["ignore_ssl"] = params["ignore_ssl"]
+
+ result = await toolCall({"name": "multipart_request", "params": toolParams})
+ statusCode = result.get("statusCode")
+ success = statusCode is not None and statusCode >= 200 and statusCode < 400
+ return {"success": success, "message": "文件上传完成", "data": result}
+
+
+async def manage_cookies(params):
+ toolParams = {
+ "action": params["action"],
+ }
+ if params.get("domain") is not None:
+ toolParams["domain"] = params["domain"]
+ if params.get("cookies") is not None:
+ toolParams["cookies"] = params["cookies"]
+
+ result = await toolCall({"name": "manage_cookies", "params": toolParams})
+ statusCode = result.get("statusCode")
+ success = statusCode is not None and statusCode >= 200 and statusCode < 400
+ return {"success": success, "message": "Cookies 操作完成", "data": result}
+
+
+async def wrapToolExecution(func, params):
+ try:
+ result = await func(params)
+ complete(result)
+ except Exception as error:
+ console.error(f"Tool {func.__name__} failed unexpectedly", error)
+ msg = getattr(error, "message", str(error))
+ complete({
+ "success": False,
+ "message": f"工具执行时发生意外错误: {msg}",
+ })
+
+
+async def main():
+ results = []
+
+ # 这些工具可能对外发请求 / 写 cookies,默认不做自动化演示。
+ results.append({"tool": "http_request", "result": {"success": None, "message": "未测试(会对外发起网络请求)"}})
+ results.append({"tool": "multipart_request", "result": {"success": None, "message": "未测试(会上传文件/对外发起网络请求)"}})
+ results.append({"tool": "manage_cookies", "result": {"success": None, "message": "未测试(会读写 cookies)"}})
+
+ complete({
+ "success": True,
+ "message": "拓展 HTTP 工具包加载完成(未执行网络请求测试)",
+ "data": {"results": results}
+ })
+
+
+async def http_request_wrapper(params):
+ await wrapToolExecution(http_request, params)
+
+
+async def multipart_request_wrapper(params):
+ await wrapToolExecution(multipart_request, params)
+
+
+async def manage_cookies_wrapper(params):
+ await wrapToolExecution(manage_cookies, params)
+
+
+exports.http_request = http_request_wrapper
+exports.multipart_request = multipart_request_wrapper
+exports.manage_cookies = manage_cookies_wrapper
+exports.main = main
diff --git a/operit_py/extended_memory_tools.py b/operit_py/extended_memory_tools.py
new file mode 100644
index 0000000..5601d39
--- /dev/null
+++ b/operit_py/extended_memory_tools.py
@@ -0,0 +1,331 @@
+# METADATA
+# {
+# "name": "extended_memory_tools",
+#
+# "display_name": {
+# "zh": "增强记忆工具",
+# "en": "Extended Memory Tools"
+# },
+# "description": {
+# "zh": "拓展记忆工具包:提供创建/更新/删除/查询/链接记忆,以及更新用户偏好的能力(默认工具中仅保留 query/get/query_links)。",
+# "en": "Extended memory tools: create/update/delete/query/link memories and update user preferences (default tools only keep query/get/query_links)."
+# },
+# "category": "Memory",
+# "enabledByDefault": true,
+# "tools": [
+# {
+# "name": "create_memory",
+# "description": { "zh": "创建新的记忆节点。", "en": "Create a new memory node." },
+# "parameters": [
+# { "name": "title", "description": { "zh": "记忆标题", "en": "Memory title" }, "type": "string", "required": true },
+# { "name": "content", "description": { "zh": "记忆内容", "en": "Memory content" }, "type": "string", "required": true },
+# { "name": "content_type", "description": { "zh": "可选:内容类型,默认 text/plain", "en": "Optional: content type (default: text/plain)" }, "type": "string", "required": false },
+# { "name": "source", "description": { "zh": "可选:来源,默认 ai_created", "en": "Optional: source (default: ai_created)" }, "type": "string", "required": false },
+# { "name": "folder_path", "description": { "zh": "可选:文件夹路径,默认空", "en": "Optional: folder path (default: empty)" }, "type": "string", "required": false },
+# { "name": "tags", "description": { "zh": "可选:标签(逗号分隔字符串)", "en": "Optional: tags (comma-separated string)" }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "update_memory",
+# "description": { "zh": "按标题更新已有记忆节点。", "en": "Update an existing memory node by title." },
+# "parameters": [
+# { "name": "old_title", "description": { "zh": "原始标题(用于定位记忆)", "en": "Old title (to locate the memory)" }, "type": "string", "required": true },
+# { "name": "new_title", "description": { "zh": "可选:新标题(重命名)", "en": "Optional: new title (rename)" }, "type": "string", "required": false },
+# { "name": "content", "description": { "zh": "可选:新内容", "en": "Optional: new content" }, "type": "string", "required": false },
+# { "name": "content_type", "description": { "zh": "可选:内容类型", "en": "Optional: content type" }, "type": "string", "required": false },
+# { "name": "source", "description": { "zh": "可选:来源", "en": "Optional: source" }, "type": "string", "required": false },
+# { "name": "credibility", "description": { "zh": "可选:可信度 0-1", "en": "Optional: credibility 0-1" }, "type": "number", "required": false },
+# { "name": "importance", "description": { "zh": "可选:重要性 0-1", "en": "Optional: importance 0-1" }, "type": "number", "required": false },
+# { "name": "folder_path", "description": { "zh": "可选:文件夹路径", "en": "Optional: folder path" }, "type": "string", "required": false },
+# { "name": "tags", "description": { "zh": "可选:标签(逗号分隔字符串)", "en": "Optional: tags (comma-separated string)" }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "delete_memory",
+# "description": { "zh": "按标题删除记忆节点(不可逆)。", "en": "Delete a memory node by title (irreversible)." },
+# "parameters": [
+# { "name": "title", "description": { "zh": "要删除的记忆标题", "en": "Memory title to delete" }, "type": "string", "required": true }
+# ]
+# },
+# {
+# "name": "move_memory",
+# "description": { "zh": "批量移动记忆到新文件夹。可按标题列表和/或来源文件夹筛选。", "en": "Move memories to another folder in batch. Filter by titles and/or source folder." },
+# "parameters": [
+# { "name": "target_folder_path", "description": { "zh": "目标文件夹路径(空字符串表示未分类)", "en": "Target folder path (empty string means uncategorized)" }, "type": "string", "required": true },
+# { "name": "titles", "description": { "zh": "可选:标题列表(逗号或换行分隔)", "en": "Optional: title list (comma/newline separated)" }, "type": "string", "required": false },
+# { "name": "source_folder_path", "description": { "zh": "可选:来源文件夹路径(空字符串表示未分类)", "en": "Optional: source folder path (empty string means uncategorized)" }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "link_memories",
+# "description": { "zh": "创建两条记忆之间的语义链接。", "en": "Create a semantic link between two memories." },
+# "parameters": [
+# { "name": "source_title", "description": { "zh": "源记忆标题", "en": "Source memory title" }, "type": "string", "required": true },
+# { "name": "target_title", "description": { "zh": "目标记忆标题", "en": "Target memory title" }, "type": "string", "required": true },
+# { "name": "link_type", "description": { "zh": "可选:关系类型,默认 related", "en": "Optional: link type (default: related)" }, "type": "string", "required": false },
+# { "name": "weight", "description": { "zh": "可选:强度 0-1,默认 0.7", "en": "Optional: weight 0-1 (default: 0.7)" }, "type": "number", "required": false },
+# { "name": "description", "description": { "zh": "可选:关系描述", "en": "Optional: relationship description" }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "query_memory_links",
+# "description": { "zh": "查询记忆链接(可按 ID/源标题/目标标题/关系类型过滤)。", "en": "Query memory links (filter by id/source/target/type)." },
+# "parameters": [
+# { "name": "link_id", "description": { "zh": "可选:链接ID", "en": "Optional: link id" }, "type": "number", "required": false },
+# { "name": "source_title", "description": { "zh": "可选:源记忆标题", "en": "Optional: source memory title" }, "type": "string", "required": false },
+# { "name": "target_title", "description": { "zh": "可选:目标记忆标题", "en": "Optional: target memory title" }, "type": "string", "required": false },
+# { "name": "link_type", "description": { "zh": "可选:关系类型", "en": "Optional: link type" }, "type": "string", "required": false },
+# { "name": "limit", "description": { "zh": "可选:返回上限 1-200,默认20", "en": "Optional: limit 1-200, default 20" }, "type": "number", "required": false }
+# ]
+# },
+# {
+# "name": "update_memory_link",
+# "description": { "zh": "更新记忆链接(按 link_id 或 source/target/link_type 定位)。", "en": "Update a memory link (by link_id or source/target/link_type)." },
+# "parameters": [
+# { "name": "link_id", "description": { "zh": "可选:链接ID(优先使用)", "en": "Optional: link ID (preferred)" }, "type": "number", "required": false },
+# { "name": "source_title", "description": { "zh": "可选:源记忆标题(未提供 link_id 时使用)", "en": "Optional: source title (used when link_id is not provided)" }, "type": "string", "required": false },
+# { "name": "target_title", "description": { "zh": "可选:目标记忆标题(未提供 link_id 时使用)", "en": "Optional: target title (used when link_id is not provided)" }, "type": "string", "required": false },
+# { "name": "link_type", "description": { "zh": "可选:当前关系类型(用于唯一定位)", "en": "Optional: current relation type (for unique resolution)" }, "type": "string", "required": false },
+# { "name": "new_link_type", "description": { "zh": "可选:新的关系类型", "en": "Optional: new relation type" }, "type": "string", "required": false },
+# { "name": "weight", "description": { "zh": "可选:新的强度 0-1", "en": "Optional: new weight 0-1" }, "type": "number", "required": false },
+# { "name": "description", "description": { "zh": "可选:新的关系描述", "en": "Optional: new relationship description" }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "delete_memory_link",
+# "description": { "zh": "删除记忆链接(按 link_id 或 source/target/link_type 定位)。", "en": "Delete a memory link (by link_id or source/target/link_type)." },
+# "parameters": [
+# { "name": "link_id", "description": { "zh": "可选:链接ID(优先使用)", "en": "Optional: link ID (preferred)" }, "type": "number", "required": false },
+# { "name": "source_title", "description": { "zh": "可选:源记忆标题(未提供 link_id 时使用)", "en": "Optional: source title (used when link_id is not provided)" }, "type": "string", "required": false },
+# { "name": "target_title", "description": { "zh": "可选:目标记忆标题(未提供 link_id 时使用)", "en": "Optional: target title (used when link_id is not provided)" }, "type": "string", "required": false },
+# { "name": "link_type", "description": { "zh": "可选:关系类型(用于唯一定位)", "en": "Optional: relation type (for unique resolution)" }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "update_user_preferences",
+# "description": { "zh": "更新用户偏好信息(至少提供一个字段)。", "en": "Update user preferences (provide at least one field)." },
+# "parameters": [
+# { "name": "birth_date", "description": { "zh": "可选:出生日期(Unix 毫秒时间戳)", "en": "Optional: birth date (Unix ms timestamp)" }, "type": "number", "required": false },
+# { "name": "gender", "description": { "zh": "可选:性别", "en": "Optional: gender" }, "type": "string", "required": false },
+# { "name": "personality", "description": { "zh": "可选:性格特征", "en": "Optional: personality" }, "type": "string", "required": false },
+# { "name": "identity", "description": { "zh": "可选:身份/角色", "en": "Optional: identity/role" }, "type": "string", "required": false },
+# { "name": "occupation", "description": { "zh": "可选:职业", "en": "Optional: occupation" }, "type": "string", "required": false },
+# { "name": "ai_style", "description": { "zh": "可选:偏好 AI 交互风格", "en": "Optional: preferred AI interaction style" }, "type": "string", "required": false }
+# ]
+# }
+# ]
+# }
+
+import re
+
+
+def resolveCallerCardId():
+ getCallerCardId_fn = globals().get("getCallerCardId")
+ if not callable(getCallerCardId_fn):
+ return None
+ callerCardId = str(getCallerCardId_fn() or "").strip()
+ return callerCardId or None
+
+
+async def create_memory(params):
+ result = await Tools.Memory.create({
+ "title": params["title"],
+ "content": params["content"],
+ "contentType": params.get("content_type"),
+ "source": params.get("source"),
+ "folderPath": params.get("folder_path"),
+ "tags": params.get("tags"),
+ "callerCardId": resolveCallerCardId(),
+ })
+ return {"success": isinstance(result, str) and len(result) > 0, "message": "记忆创建完成", "data": result}
+
+
+async def update_memory(params):
+ result = await Tools.Memory.update({
+ "oldTitle": params["old_title"],
+ "newTitle": params.get("new_title"),
+ "content": params.get("content"),
+ "contentType": params.get("content_type"),
+ "source": params.get("source"),
+ "credibility": params.get("credibility"),
+ "importance": params.get("importance"),
+ "folderPath": params.get("folder_path"),
+ "tags": params.get("tags"),
+ "callerCardId": resolveCallerCardId(),
+ })
+ return {"success": isinstance(result, str) and len(result) > 0, "message": "记忆更新完成", "data": result}
+
+
+async def delete_memory(params):
+ result = await Tools.Memory.deleteMemory({
+ "title": params["title"],
+ "callerCardId": resolveCallerCardId(),
+ })
+ return {"success": isinstance(result, str) and len(result) > 0, "message": "记忆删除完成", "data": result}
+
+
+async def move_memory(params):
+ titles = (
+ [s.strip() for s in re.split(r'[,\n|]', params["titles"]) if s.strip()]
+ if params.get("titles")
+ else None
+ )
+ result = await Tools.Memory.move({
+ "targetFolderPath": params["target_folder_path"],
+ "titles": titles,
+ "sourceFolderPath": params.get("source_folder_path"),
+ "callerCardId": resolveCallerCardId(),
+ })
+ return {"success": isinstance(result, str) and len(result) > 0, "message": "记忆移动完成", "data": result}
+
+
+async def link_memories(params):
+ result = await Tools.Memory.link({
+ "sourceTitle": params["source_title"],
+ "targetTitle": params["target_title"],
+ "linkType": params.get("link_type"),
+ "weight": params.get("weight"),
+ "description": params.get("description"),
+ "callerCardId": resolveCallerCardId(),
+ })
+ return {"success": bool(result), "message": "记忆链接创建完成", "data": result}
+
+
+async def query_memory_links(params):
+ result = await Tools.Memory.queryLinks({
+ "linkId": params.get("link_id"),
+ "sourceTitle": params.get("source_title"),
+ "targetTitle": params.get("target_title"),
+ "linkType": params.get("link_type"),
+ "limit": params.get("limit"),
+ "callerCardId": resolveCallerCardId(),
+ })
+ return {"success": bool(result), "message": "记忆链接查询完成", "data": result}
+
+
+async def update_memory_link(params):
+ result = await Tools.Memory.updateLink({
+ "linkId": params.get("link_id"),
+ "sourceTitle": params.get("source_title"),
+ "targetTitle": params.get("target_title"),
+ "linkType": params.get("link_type"),
+ "newLinkType": params.get("new_link_type"),
+ "weight": params.get("weight"),
+ "description": params.get("description"),
+ "callerCardId": resolveCallerCardId(),
+ })
+ return {"success": bool(result), "message": "记忆链接更新完成", "data": result}
+
+
+async def delete_memory_link(params):
+ result = await Tools.Memory.deleteLink({
+ "linkId": params.get("link_id"),
+ "sourceTitle": params.get("source_title"),
+ "targetTitle": params.get("target_title"),
+ "linkType": params.get("link_type"),
+ "callerCardId": resolveCallerCardId(),
+ })
+ success = (len(result) > 0) if isinstance(result, str) else bool(result)
+ return {"success": success, "message": "记忆链接删除完成", "data": result}
+
+
+async def update_user_preferences(params):
+ toolParams = {}
+ if params.get("birth_date") is not None:
+ toolParams["birth_date"] = params["birth_date"]
+ if params.get("gender") is not None:
+ toolParams["gender"] = params["gender"]
+ if params.get("personality") is not None:
+ toolParams["personality"] = params["personality"]
+ if params.get("identity") is not None:
+ toolParams["identity"] = params["identity"]
+ if params.get("occupation") is not None:
+ toolParams["occupation"] = params["occupation"]
+ if params.get("ai_style") is not None:
+ toolParams["ai_style"] = params["ai_style"]
+
+ result = await toolCall({"name": "update_user_preferences", "params": toolParams})
+ success = (len(result) > 0) if isinstance(result, str) else bool(result)
+ return {"success": success, "message": "用户偏好更新完成", "data": result}
+
+
+async def wrapToolExecution(func, params):
+ try:
+ result = await func(params)
+ complete(result)
+ except Exception as error:
+ console.error(f"Tool {func.__name__} failed unexpectedly", error)
+ msg = getattr(error, "message", str(error))
+ complete({
+ "success": False,
+ "message": f"工具执行时发生意外错误: {msg}",
+ })
+
+
+async def main():
+ results = []
+
+ # 这些工具都可能修改记忆/偏好,默认不做自动化演示,避免污染用户数据。
+ results.append({"tool": "create_memory", "result": {"success": None, "message": "未测试(会写入记忆库)"}})
+ results.append({"tool": "update_memory", "result": {"success": None, "message": "未测试(会修改记忆库)"}})
+ results.append({"tool": "delete_memory", "result": {"success": None, "message": "未测试(会删除记忆库数据)"}})
+ results.append({"tool": "move_memory", "result": {"success": None, "message": "未测试(会批量修改记忆文件夹)"}})
+ results.append({"tool": "link_memories", "result": {"success": None, "message": "未测试(会修改记忆库链接)"}})
+ results.append({"tool": "query_memory_links", "result": {"success": None, "message": "未测试(只读查询)"}})
+ results.append({"tool": "update_memory_link", "result": {"success": None, "message": "未测试(会修改记忆库链接)"}})
+ results.append({"tool": "delete_memory_link", "result": {"success": None, "message": "未测试(会删除记忆库链接)"}})
+ results.append({"tool": "update_user_preferences", "result": {"success": None, "message": "未测试(会修改用户偏好)"}})
+
+ complete({
+ "success": True,
+ "message": "拓展记忆工具包加载完成(未执行破坏性测试)",
+ "data": {"results": results}
+ })
+
+
+async def create_memory_wrapper(params):
+ await wrapToolExecution(create_memory, params)
+
+
+async def update_memory_wrapper(params):
+ await wrapToolExecution(update_memory, params)
+
+
+async def delete_memory_wrapper(params):
+ await wrapToolExecution(delete_memory, params)
+
+
+async def move_memory_wrapper(params):
+ await wrapToolExecution(move_memory, params)
+
+
+async def link_memories_wrapper(params):
+ await wrapToolExecution(link_memories, params)
+
+
+async def query_memory_links_wrapper(params):
+ await wrapToolExecution(query_memory_links, params)
+
+
+async def update_memory_link_wrapper(params):
+ await wrapToolExecution(update_memory_link, params)
+
+
+async def delete_memory_link_wrapper(params):
+ await wrapToolExecution(delete_memory_link, params)
+
+
+async def update_user_preferences_wrapper(params):
+ await wrapToolExecution(update_user_preferences, params)
+
+
+exports.create_memory = create_memory_wrapper
+exports.update_memory = update_memory_wrapper
+exports.delete_memory = delete_memory_wrapper
+exports.move_memory = move_memory_wrapper
+exports.link_memories = link_memories_wrapper
+exports.query_memory_links = query_memory_links_wrapper
+exports.update_memory_link = update_memory_link_wrapper
+exports.delete_memory_link = delete_memory_link_wrapper
+exports.update_user_preferences = update_user_preferences_wrapper
+exports.main = main
diff --git a/operit_py/ffmpeg.py b/operit_py/ffmpeg.py
new file mode 100644
index 0000000..d588190
--- /dev/null
+++ b/operit_py/ffmpeg.py
@@ -0,0 +1,131 @@
+# METADATA
+# {
+# "name": "ffmpeg",
+#
+# "display_name": {
+# "zh": "FFmpeg 工具集",
+# "en": "FFmpeg Toolkit"
+# },
+# "description": {
+# "zh": "提供FFmpeg工具,用于处理多媒体内容。",
+# "en": "FFmpeg utilities for processing multimedia content."
+# },
+# "enabledByDefault": true,
+# "category": "Media",
+# "tools": [
+# {
+# "name": "ffmpeg_execute",
+# "description": { "zh": "执行自定义FFmpeg命令(仅填写参数,不要包含前缀 ffmpeg)。", "en": "Execute a custom FFmpeg command (arguments only; do not include the leading ffmpeg)." },
+# "parameters": [
+# { "name": "command", "description": { "zh": "要执行的FFmpeg命令参数(不要包含前缀 ffmpeg)", "en": "FFmpeg command arguments to execute (do not include the leading ffmpeg)" }, "type": "string", "required": true }
+# ]
+# },
+# {
+# "name": "ffmpeg_info",
+# "description": { "zh": "获取FFmpeg系统信息,包括版本、构建配置和支持的编解码器。", "en": "Get FFmpeg system info, including version, build config, and supported codecs." },
+# "parameters": []
+# },
+# {
+# "name": "ffmpeg_convert",
+# "description": { "zh": "使用简化参数转换视频文件。", "en": "Convert a video file using simplified parameters." },
+# "parameters": [
+# { "name": "input_path", "description": { "zh": "源视频文件路径", "en": "Input video file path" }, "type": "string", "required": true },
+# { "name": "output_path", "description": { "zh": "目标视频文件路径", "en": "Output video file path" }, "type": "string", "required": true },
+# { "name": "video_codec", "description": { "zh": "可选,要使用的视频编解码器。推荐使用 'h264'。支持的值: 'h264', 'hevc', 'vp8', 'vp9', 'av1', 'libx265', 'libvpx', 'libaom', 'mpeg4', 'mjpeg', 'prores'。", "en": "Optional. Video codec to use. Prefer 'h264'. Supported values: 'h264', 'hevc', 'vp8', 'vp9', 'av1', 'libx265', 'libvpx', 'libaom', 'mpeg4', 'mjpeg', 'prores'." }, "type": "string", "required": false },
+# { "name": "audio_codec", "description": { "zh": "可选,要使用的音频编解码器。支持的值: 'aac', 'mp3', 'opus', 'vorbis', 'flac', 'pcm', 'wav', 'ac3', 'eac3'。", "en": "Optional. Audio codec to use. Supported values: 'aac', 'mp3', 'opus', 'vorbis', 'flac', 'pcm', 'wav', 'ac3', 'eac3'." }, "type": "string", "required": false },
+# { "name": "resolution", "description": { "zh": "可选,输出分辨率,例如 '1280x720'。", "en": "Optional. Output resolution, e.g. '1280x720'." }, "type": "string", "required": false },
+# { "name": "bitrate", "description": { "zh": "可选,视频比特率,例如 '1000k'。", "en": "Optional. Video bitrate, e.g. '1000k'." }, "type": "string", "required": false }
+# ]
+# }
+# ]
+# }
+
+import json
+
+
+async def ffmpeg_execute(params):
+ result = await Tools.FFmpeg.execute(params["command"])
+ returnCode = result["returnCode"]
+ return {
+ "success": returnCode == 0,
+ "message": "FFmpeg command executed successfully." if returnCode == 0 else f"FFmpeg command failed with return code {returnCode}.",
+ "data": result["output"]
+ }
+
+
+async def ffmpeg_info():
+ result = await Tools.FFmpeg.info()
+ returnCode = result["returnCode"]
+ return {
+ "success": returnCode == 0,
+ "message": "FFmpeg info retrieved successfully." if returnCode == 0 else "Failed to retrieve FFmpeg info.",
+ "data": result["output"]
+ }
+
+
+async def ffmpeg_convert(params):
+ result = await Tools.FFmpeg.convert(params["input_path"], params["output_path"], {
+ "video_codec": params.get("video_codec"),
+ "audio_codec": params.get("audio_codec"),
+ "resolution": params.get("resolution"),
+ "bitrate": params.get("bitrate"),
+ })
+ returnCode = result["returnCode"]
+ return {
+ "success": returnCode == 0,
+ "message": "FFmpeg conversion completed successfully." if returnCode == 0 else f"FFmpeg conversion failed with return code {returnCode}.",
+ "data": result["output"]
+ }
+
+
+async def wrapToolExecution(func, params):
+ try:
+ import inspect as _inspect
+ sig = _inspect.signature(func)
+ if len(sig.parameters) > 0:
+ result = await func(params)
+ else:
+ result = await func()
+ complete(result)
+ except Exception as error:
+ console.error(f"Tool {func.__name__} failed unexpectedly", error)
+ msg = getattr(error, "message", str(error))
+ complete({
+ "success": False,
+ "message": f"工具执行时发生意外错误: {msg}",
+ })
+
+
+async def main():
+ console.log("--- FFmpeg Tools Test ---")
+
+ console.log("\n[1/3] Testing ffmpeg_info...")
+ infoResult = await ffmpeg_info()
+ console.log(json.dumps(infoResult, indent=2, ensure_ascii=False))
+
+ console.log("\n[2/3] Testing ffmpeg_execute (example: getting help for a decoder)...")
+ executeResult = await ffmpeg_execute({"command": "-h decoder=h264"})
+ console.log(json.dumps(executeResult, indent=2, ensure_ascii=False))
+
+ console.log("\n[3/3] Testing ffmpeg_convert (example)...")
+ console.log("Skipping ffmpeg_convert test as it requires input files.")
+
+ complete({"success": True, "message": "Test finished."})
+
+
+async def ffmpeg_execute_wrapper(params):
+ await wrapToolExecution(ffmpeg_execute, params)
+
+
+async def ffmpeg_info_wrapper(params=None):
+ await wrapToolExecution(ffmpeg_info, params)
+
+
+async def ffmpeg_convert_wrapper(params):
+ await wrapToolExecution(ffmpeg_convert, params)
+
+
+exports.ffmpeg_execute = ffmpeg_execute_wrapper
+exports.ffmpeg_info = ffmpeg_info_wrapper
+exports.ffmpeg_convert = ffmpeg_convert_wrapper
+exports.main = main
diff --git a/operit_py/file_converter.py b/operit_py/file_converter.py
new file mode 100644
index 0000000..02158a2
--- /dev/null
+++ b/operit_py/file_converter.py
@@ -0,0 +1,230 @@
+# METADATA
+# {
+# "name": "file_converter",
+#
+# "display_name": {
+# "zh": "文件转换器",
+# "en": "File Converter"
+# },
+# "description": {
+# "zh": "提供全面的文件格式转换功能。支持常见的音频/视频(如 MP4、MOV、MP3、WAV)、图像(如 JPG、PNG、WEBP)以及文档(如 Markdown、HTML、DOCX、PDF)之间的相互转换。",
+# "en": "Comprehensive file format conversion. Supports converting between common audio/video (MP4, MOV, MP3, WAV), images (JPG, PNG, WEBP), and documents (Markdown, HTML, DOCX, PDF)."
+# },
+# "enabledByDefault": true,
+# "category": "File",
+# "tools": [
+# {
+# "name": "convert_file",
+# "description": {
+# "zh": "调用 FFmpeg、ImageMagick 和 Pandoc 等外部命令行工具,转换文件的格式。支持音视频、图像和文档等多种类型。如果检测到工具未安装,会尝试自动安装。",
+# "en": "Convert files using external CLI tools such as FFmpeg, ImageMagick, and Pandoc. Supports audio/video, images, and documents. If a required tool is missing, it will attempt to install it automatically."
+# },
+# "parameters": [
+# { "name": "input_path", "description": { "zh": "输入文件的路径。", "en": "Input file path." }, "type": "string", "required": true },
+# { "name": "output_path", "description": { "zh": "输出文件的路径。扩展名决定了目标格式。", "en": "Output file path. The file extension determines the target format." }, "type": "string", "required": true },
+# { "name": "options", "description": { "zh": "用于转换工具的可选命令行选项 (例如, 为 ImageMagick 设置 '-quality 80' )。", "en": "Optional CLI options for the conversion tool (e.g. set '-quality 80' for ImageMagick)." }, "type": "string", "required": false }
+# ]
+# }
+# ]
+# }
+
+import traceback
+
+terminalSessionId = None
+
+
+async def getTerminalSessionId():
+ global terminalSessionId
+ if terminalSessionId:
+ return terminalSessionId
+ session = await Tools.System.terminal.create("file_converter_session")
+ terminalSessionId = session["sessionId"]
+ return terminalSessionId
+
+
+async def executeTerminalCommand(command, timeoutMs=None):
+ sessionId = await getTerminalSessionId()
+ return await Tools.System.terminal.exec(sessionId, command)
+
+
+async def checkAndInstall(toolName, packageName):
+ """Checks if a command-line tool is installed, and attempts to install it if not found."""
+ console.log(f"Checking for {toolName}...")
+ checkCmd = f"command -v {toolName}"
+ checkResult = await executeTerminalCommand(checkCmd)
+
+ if checkResult["exitCode"] == 0 and checkResult["output"].strip() != "":
+ console.log(f"{toolName} is already installed at: {checkResult['output'].strip()}")
+ return True
+
+ console.log(f"{toolName} not found. Attempting to install package: {packageName}...")
+
+ # Assuming an apt-based system (like Debian/Ubuntu).
+ updateCmd = "apt-get update"
+ console.log(f"Running: {updateCmd}")
+ updateResult = await executeTerminalCommand(updateCmd)
+ if updateResult["exitCode"] != 0:
+ console.warn(f"'apt-get update' failed. This might be okay if caches are fresh, but installation may fail.\nOutput: {updateResult['output']}")
+
+ installCmd = f"apt-get install -y {packageName}"
+ console.log(f"Running: {installCmd}")
+ installResult = await executeTerminalCommand(installCmd)
+
+ if installResult["exitCode"] != 0:
+ console.error(f"Failed to install {packageName}: {installResult['output']}")
+ raise Exception(f"Failed to install required tool: {toolName} (package: {packageName}). Please try installing it manually.")
+
+ console.log(f"{packageName} installed successfully.")
+ return True
+
+
+def getConverterInfo(inputPath, outputPath, options=None):
+ """Determines the appropriate conversion tool and command based on file extensions."""
+ def getExt(path):
+ return path.split(".")[-1].lower()
+
+ inputExt = getExt(inputPath)
+ outputExt = getExt(outputPath)
+
+ def isAudioVideo(ext):
+ return ext in ["mp4", "mkv", "avi", "mov", "flv", "webm", "mp3", "wav", "ogg", "flac", "aac", "m4a", "wma", "wmv"]
+
+ def isImage(ext):
+ return ext in ["jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", "ico", "svg"]
+
+ def isDocument(ext):
+ return ext in ["md", "html", "docx", "pdf", "txt", "epub", "odt", "rtf", "tex", "rst", "json", "csv"]
+
+ if isAudioVideo(inputExt) or isAudioVideo(outputExt):
+ return {
+ "tool": "ffmpeg",
+ "pkg": "ffmpeg",
+ "command": f'ffmpeg -y -i "{inputPath}" {options or ""} "{outputPath}"'
+ }
+
+ if isImage(inputExt) or isImage(outputExt):
+ return {
+ "tool": "convert",
+ "pkg": "imagemagick",
+ "command": f'convert "{inputPath}" {options or ""} "{outputPath}"'
+ }
+
+ if isDocument(inputExt) or isDocument(outputExt):
+ return {
+ "tool": "pandoc",
+ "pkg": "pandoc",
+ "command": f'pandoc "{inputPath}" -o "{outputPath}" {options or ""}'
+ }
+
+ raise Exception(f"Unsupported or ambiguous file conversion from .{inputExt} to .{outputExt}.")
+
+
+async def convert_file(params):
+ """The core logic for the convert_file tool."""
+ input_path = params["input_path"]
+ output_path = params["output_path"]
+ options = params.get("options")
+
+ fileExists = await Tools.Files.exists(input_path)
+ if not fileExists["exists"]:
+ raise Exception(f"Input file not found: {input_path}")
+
+ converter = getConverterInfo(input_path, output_path, options)
+
+ await checkAndInstall(converter["tool"], converter["pkg"])
+
+ console.log(f"Executing conversion command: {converter['command']}")
+ result = await executeTerminalCommand(converter["command"])
+
+ if result["exitCode"] != 0:
+ raise Exception(f"Conversion failed. Exit code: {result['exitCode']}\nOutput:\n{result['output']}")
+
+ outputExists = await Tools.Files.exists(output_path)
+ if not outputExists["exists"]:
+ # Sometimes a tool exits with 0 but fails, writing to stderr.
+ raise Exception(f"Conversion process finished, but output file was not created at: {output_path}\nTerminal Output:\n{result['output']}")
+
+ return {
+ "output_path": output_path,
+ "details": f"File converted successfully and saved to {output_path}.",
+ "terminal_output": result["output"]
+ }
+
+
+async def wrap(func, params, successMessage, failMessage):
+ """A wrapper function for executing tools to provide standardized success/error handling."""
+ try:
+ result = await func(params)
+ complete({"success": True, "message": successMessage, "data": result})
+ except Exception as error:
+ msg = str(getattr(error, "message", error))
+ console.error(f"Function {func.__name__} failed! Error: {msg}")
+ complete({"success": False, "message": f"{failMessage}: {msg}", "error_stack": traceback.format_exc()})
+
+
+async def main():
+ """A main function for self-testing the capabilities of this tool package."""
+ console.log("--- Starting File Converter Tool Test ---")
+ testDir = "/sdcard/Download/converter_test"
+ await Tools.Files.mkdir(testDir, True)
+
+ # Test 1: Image conversion (PNG to JPG)
+ try:
+ console.log("\n[1/3] Testing Image Conversion (PNG -> JPG)")
+ # A simple 1x1 red pixel PNG in base64
+ pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/wcAAwAB/epv2AAAAABJRU5ErkJggg=="
+ inputPng = f"{testDir}/test.png"
+ outputJpg = f"{testDir}/test.jpg"
+ await Tools.Files.writeBinary(inputPng, pngBase64)
+
+ imageResult = await convert_file({"input_path": inputPng, "output_path": outputJpg})
+ console.log("Image conversion success:", imageResult)
+ if not (await Tools.Files.exists(outputJpg))["exists"]:
+ raise Exception("JPG file not created.")
+
+ # Verify that readBinary can read the converted image as Base64
+ jpgBinary = await Tools.Files.readBinary(outputJpg)
+ console.log("ReadBinary success: size=", jpgBinary["size"], "bytes, base64 length=", len(jpgBinary["contentBase64"]))
+ except Exception as e:
+ msg = str(getattr(e, "message", e))
+ console.error("Image conversion test failed:", msg)
+
+ # Test 2: Document conversion (MD to HTML)
+ try:
+ console.log("\n[2/3] Testing Document Conversion (MD -> HTML)")
+ inputMd = f"{testDir}/test.md"
+ outputHtml = f"{testDir}/test.html"
+ await Tools.Files.write(inputMd, "# Hello World")
+
+ docResult = await convert_file({"input_path": inputMd, "output_path": outputHtml})
+ console.log("Document conversion success:", docResult)
+ htmlContent = (await Tools.Files.read(outputHtml))["content"]
+ if " tar)")
+ inputZip = f"{testDir}/test.zip"
+ await Tools.Files.write(inputZip, "dummy content")
+ await convert_file({"input_path": inputZip, "output_path": f"{testDir}/test.tar"})
+ console.error("Unsupported conversion test FAILED: It should have thrown an error but didn't.")
+ except Exception as e:
+ msg = str(getattr(e, "message", e))
+ console.log("Unsupported conversion test PASSED as expected:", msg)
+
+ console.log("\n--- File Converter Tool Test Finished ---")
+ await Tools.Files.deleteFile(testDir, True)
+ console.log("Cleaned up test directory.")
+ complete({"success": True, "message": "All tests finished."})
+
+
+async def convert_file_wrapper(p):
+ await wrap(convert_file, p, "File conversion successful.", "File conversion failed.")
+
+
+exports.convert_file = convert_file_wrapper
+exports.main = main
diff --git a/operit_py/google_search.py b/operit_py/google_search.py
new file mode 100644
index 0000000..8c0d0d9
--- /dev/null
+++ b/operit_py/google_search.py
@@ -0,0 +1,219 @@
+# METADATA
+# {
+# "name": "google_search",
+#
+# "display_name": {
+# "zh": "Google 搜索",
+# "en": "Google Search"
+# },
+# "description": {
+# "zh": "提供 Google 普通搜索与 Google Scholar 学术搜索能力,支持设置语言与返回条数。",
+# "en": "Google web search and Google Scholar search, with configurable language and result count."
+# },
+# "enabledByDefault": false,
+# "category": "Search",
+# "tools": [
+# {
+# "name": "search_web",
+# "description": { "zh": "执行 Google 普通搜索,返回网页搜索结果。", "en": "Perform a regular Google search and return web results." },
+# "parameters": [
+# { "name": "query", "description": { "zh": "搜索关键词", "en": "Search query" }, "type": "string", "required": true },
+# { "name": "max_results", "description": { "zh": "返回结果数量,默认 10,最大 20", "en": "Number of results to return (default: 10, max: 20)" }, "type": "number", "required": false },
+# { "name": "language", "description": { "zh": "界面语言参数,默认 en", "en": "Interface language (default: en)" }, "type": "string", "required": false },
+# { "name": "region", "description": { "zh": "地区参数,例如 us、cn。默认 us", "en": "Region parameter, e.g. us, cn (default: us)" }, "type": "string", "required": false },
+# { "name": "includeLinks", "description": { "zh": "是否在结果中包含可点击的链接列表,默认为false。", "en": "Whether to include a clickable link list in results (default: false)" }, "type": "boolean", "required": false }
+# ]
+# },
+# {
+# "name": "search_scholar",
+# "description": { "zh": "执行 Google Scholar 学术搜索,返回学术文献结果。", "en": "Perform a Google Scholar search and return academic results." },
+# "parameters": [
+# { "name": "query", "description": { "zh": "搜索关键词", "en": "Search query" }, "type": "string", "required": true },
+# { "name": "max_results", "description": { "zh": "返回结果数量,默认 10,最大 20", "en": "Number of results to return (default: 10, max: 20)" }, "type": "number", "required": false },
+# { "name": "language", "description": { "zh": "界面语言参数,默认 en", "en": "Interface language (default: en)" }, "type": "string", "required": false },
+# { "name": "includeLinks", "description": { "zh": "是否在结果中包含可点击的链接列表,默认为false。", "en": "Whether to include a clickable link list in results (default: false)" }, "type": "boolean", "required": false }
+# ]
+# },
+# {
+# "name": "search_scholar_mirror",
+# "description": { "zh": "通过镜像站执行 Google Scholar 学术搜索,以绕过人机验证。", "en": "Use a mirror site to perform Google Scholar searches to bypass CAPTCHA checks." },
+# "parameters": [
+# { "name": "query", "description": { "zh": "搜索关键词", "en": "Search query" }, "type": "string", "required": true },
+# { "name": "max_results", "description": { "zh": "返回结果数量,默认 10,最大 20", "en": "Number of results to return (default: 10, max: 20)" }, "type": "number", "required": false },
+# { "name": "language", "description": { "zh": "界面语言参数,默认 en", "en": "Interface language (default: en)" }, "type": "string", "required": false },
+# { "name": "includeLinks", "description": { "zh": "是否在结果中包含可点击的链接列表,默认为false。", "en": "Whether to include a clickable link list in results (default: false)" }, "type": "boolean", "required": false }
+# ]
+# }
+# ]
+# }
+
+import json
+import re
+from urllib.parse import quote
+
+GOOGLE_SEARCH_URL = "https://www.google.com/search"
+GOOGLE_SCHOLAR_URL = "https://scholar.google.com/scholar"
+GOOGLE_SCHOLAR_MIRRORS = [
+ "https://xs.cntpj.com/scholar",
+]
+MAX_RESULTS = 20
+
+
+def buildUrl(base, params):
+ queryString = "&".join(f"{quote(k)}={quote(v)}" for k, v in params.items())
+ return f"{base}?{queryString}"
+
+
+def getHostname(rawUrl):
+ match = re.match(r'^[a-zA-Z][a-zA-Z\d+\-.]*:\/\/([^/?#]+)', rawUrl.strip())
+ if not match or not match.group(1):
+ return rawUrl
+ authority = match.group(1)
+ host = authority.split("@")[-1] or authority if "@" in authority else authority
+ return re.sub(r':\d+$', '', host)
+
+
+async def fetchHtmlViaWebVisit(url):
+ result = await Tools.Net.visit(url)
+ # The result can be a string if the underlying tool returns a simple string.
+ # We'll normalize it to a dict object.
+ if isinstance(result, str):
+ return {
+ "url": url,
+ "title": "",
+ "content": result,
+ "links": [],
+ }
+ return result
+
+
+# 解析相关逻辑已移除,直接返回 visit 的纯文本结果
+
+async def performSearch(url, includeLinks=False, sourceName=""):
+ try:
+ result = await fetchHtmlViaWebVisit(url)
+ content = result.get("content", "") if isinstance(result, dict) else ""
+
+ parts = []
+ if isinstance(result, dict) and result.get("visitKey"):
+ parts.append(f"visit_key: {result['visitKey']}")
+ if includeLinks and isinstance(result, dict) and result.get("links") and len(result["links"]) > 0:
+ linksLines = [f"[{index + 1}] {link.get('text')}" for index, link in enumerate(result["links"])]
+ parts.append("\n".join(linksLines))
+ parts.append(content)
+
+ return {
+ "success": True,
+ "message": f"{sourceName} 搜索成功",
+ "data": "\n\n".join(parts)
+ }
+ except Exception as error:
+ msg = str(getattr(error, "message", error))
+ return {
+ "success": False,
+ "message": f"{sourceName} 搜索失败: {msg}"
+ }
+
+
+async def searchWeb(params):
+ if not params.get("query") or params.get("query", "").strip() == "":
+ raise Exception("请提供有效的 query 参数。")
+ maxResults = min(max(params.get("max_results") or 10, 1), MAX_RESULTS)
+ language = params.get("language") or "en"
+ region = params.get("region") or "us"
+ url = buildUrl(GOOGLE_SEARCH_URL, {
+ "q": params["query"],
+ "hl": language,
+ "gl": region,
+ "num": str(maxResults),
+ "pws": "0",
+ })
+
+ return await performSearch(url, params.get("includeLinks"), "Google")
+
+
+async def searchScholar(params):
+ if not params.get("query") or params.get("query", "").strip() == "":
+ raise Exception("请提供有效的 query 参数。")
+ maxResults = min(max(params.get("max_results") or 10, 1), MAX_RESULTS)
+ language = params.get("language") or "en"
+ url = buildUrl(GOOGLE_SCHOLAR_URL, {
+ "q": params["query"],
+ "hl": language,
+ "as_sdt": "0,5",
+ "num": str(maxResults)
+ })
+
+ return await performSearch(url, params.get("includeLinks"), "Google Scholar")
+
+
+async def searchScholarMirror(params):
+ if not params.get("query") or params.get("query", "").strip() == "":
+ raise Exception("请提供有效的 query 参数。")
+ maxResults = min(max(params.get("max_results") or 10, 1), MAX_RESULTS)
+ language = params.get("language") or "en"
+
+ mirrorUrls = [
+ buildUrl(mirror, {
+ "q": params["query"],
+ "hl": language,
+ "as_sdt": "0,5",
+ "num": str(maxResults)
+ })
+ for mirror in GOOGLE_SCHOLAR_MIRRORS
+ ]
+
+ if len(mirrorUrls) == 0:
+ return {
+ "success": False,
+ "message": "没有可用的 Google Scholar 镜像地址。"
+ }
+
+ lastError = None
+
+ for currentUrl in mirrorUrls:
+ try:
+ searchResult = await performSearch(currentUrl, params.get("includeLinks"), f"Google Scholar 镜像 ({getHostname(currentUrl)})")
+ if searchResult.get("success") and searchResult.get("data"):
+ # Check for CAPTCHA in the content
+ if "recaptcha" in searchResult["data"] or "人机身份验证" in searchResult["data"]:
+ raise Exception("CAPTCHA required")
+ return searchResult
+ # If performSearch itself fails, it will throw and be caught below.
+ except Exception as error:
+ lastError = error
+ msg = str(getattr(error, "message", error))
+ console.log(f"Attempt with {currentUrl} failed: {msg}")
+
+ errMsg = str(getattr(lastError, "message", lastError)) if lastError else "Unknown error"
+ return {
+ "success": False,
+ "message": f"Google Scholar 镜像搜索在尝试所有镜像后失败: {errMsg}"
+ }
+
+
+async def main():
+ console.log("--- Testing Web Search ---")
+ webResult = await searchWeb({"query": "TypeScript"})
+ console.log(json.dumps(webResult, indent=2, ensure_ascii=False))
+
+ console.log("\n--- Testing Scholar Search ---")
+ scholarResult = await searchScholar({"query": "Large Language Models"})
+ console.log(json.dumps(scholarResult, indent=2, ensure_ascii=False))
+
+ console.log("\n--- Testing Scholar Mirror Search ---")
+ scholarMirrorResult = await searchScholarMirror({"query": "Quantum Computing"})
+ console.log(json.dumps(scholarMirrorResult, indent=2, ensure_ascii=False))
+
+
+def wrap(coreFunction):
+ async def wrapped(params):
+ # The core function expects the params object directly.
+ return await coreFunction(params)
+ return wrapped
+
+
+exports.search_web = wrap(searchWeb)
+exports.search_scholar = wrap(searchScholar)
+exports.search_scholar_mirror = wrap(searchScholarMirror)
+exports.main = main
diff --git a/operit_py/hex_editor.py b/operit_py/hex_editor.py
new file mode 100644
index 0000000..4bdba46
--- /dev/null
+++ b/operit_py/hex_editor.py
@@ -0,0 +1,356 @@
+# METADATA
+# {
+# "name": "hex_editor",
+# "display_name": {
+# "zh": "十六进制编辑器",
+# "en": "Hex Editor"
+# },
+# "description": {
+# "zh": "一个面向二进制文件的轻量十六进制查看与修改工具包,支持查看指定偏移的 hex 窗口、按偏移写入字节序列,以及搜索指定 hex 模式。",
+# "en": "A lightweight binary hex inspection and patching toolkit. It can read hex windows at offsets, write byte sequences at offsets, and search for hex patterns."
+# },
+# "enabledByDefault": false,
+# "category": "File",
+# "tools": [
+# {
+# "name": "read_hex_window",
+# "description": {
+# "zh": "读取二进制文件在指定偏移附近的十六进制窗口,并附带 ASCII 预览,便于快速定位和人工核对。",
+# "en": "Read a hex window from a binary file at a specific offset, including an ASCII preview for quick inspection."
+# },
+# "parameters": [
+# { "name": "file_path", "description": { "zh": "目标文件路径。", "en": "Target file path." }, "type": "string", "required": true },
+# { "name": "offset", "description": { "zh": "起始偏移,可写十进制或 0x 开头的十六进制。", "en": "Start offset in decimal or 0x-prefixed hexadecimal." }, "type": "string", "required": true },
+# { "name": "length", "description": { "zh": "读取字节数,默认 128,最大 4096。", "en": "Number of bytes to read. Default 128, max 4096." }, "type": "number", "required": false },
+# { "name": "environment", "description": { "zh": "文件环境:android 或 linux,默认 android。", "en": "File environment: android or linux. Defaults to android." }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "write_hex_bytes",
+# "description": {
+# "zh": "把给定的 hex 字节序列直接写入文件指定偏移。该工具会原地修改文件,不会自动生成回退文件。",
+# "en": "Write a hex byte sequence directly into a file at the specified offset. This patches the file in place and does not create rollback files."
+# },
+# "parameters": [
+# { "name": "file_path", "description": { "zh": "目标文件路径。", "en": "Target file path." }, "type": "string", "required": true },
+# { "name": "offset", "description": { "zh": "写入起始偏移,可写十进制或 0x 开头的十六进制。", "en": "Write offset in decimal or 0x-prefixed hexadecimal." }, "type": "string", "required": true },
+# { "name": "hex_bytes", "description": { "zh": "要写入的 hex 字节序列,例如 'DE AD BE EF' 或 'deadbeef'。", "en": "Hex bytes to write, for example 'DE AD BE EF' or 'deadbeef'." }, "type": "string", "required": true },
+# { "name": "environment", "description": { "zh": "文件环境:android 或 linux,默认 android。", "en": "File environment: android or linux. Defaults to android." }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "find_hex_pattern",
+# "description": {
+# "zh": "在二进制文件中搜索指定的 hex 模式,返回命中偏移以及每个命中点附近的十六进制窗口。",
+# "en": "Search a binary file for a hex pattern and return matched offsets with nearby hex windows."
+# },
+# "parameters": [
+# { "name": "file_path", "description": { "zh": "目标文件路径。", "en": "Target file path." }, "type": "string", "required": true },
+# { "name": "hex_bytes", "description": { "zh": "要搜索的 hex 模式,例如 '50 4B 03 04'。", "en": "Hex pattern to search for, for example '50 4B 03 04'." }, "type": "string", "required": true },
+# { "name": "max_results", "description": { "zh": "最多返回多少个命中,默认 20,最大 100。", "en": "Maximum number of matches to return. Default 20, max 100." }, "type": "number", "required": false },
+# { "name": "environment", "description": { "zh": "文件环境:android 或 linux,默认 android。", "en": "File environment: android or linux. Defaults to android." }, "type": "string", "required": false }
+# ]
+# }
+# ]
+# }
+
+import base64
+import math
+import re
+import traceback
+
+DEFAULT_WINDOW_LENGTH = 128
+MAX_WINDOW_LENGTH = 4096
+DEFAULT_MAX_RESULTS = 20
+MAX_RESULTS_LIMIT = 100
+CONTEXT_BEFORE = 16
+CONTEXT_TOTAL = 64
+BYTES_PER_LINE = 16
+
+
+def _is_finite(n):
+ return isinstance(n, (int, float)) and not isinstance(n, bool) and math.isfinite(n)
+
+
+def normalizeEnvironment(environment=None):
+ value = str(environment or "android").strip().lower()
+ if value != "android" and value != "linux":
+ raise Exception("environment 只能是 android 或 linux。")
+ return value
+
+
+def normalizeBase64Input(value):
+ cleaned = re.sub(r"\s+", "", str(value or ""))
+ cleaned = cleaned.replace("-", "+").replace("_", "/")
+ if not cleaned:
+ return ""
+ remainder = len(cleaned) % 4
+ if remainder == 1:
+ raise Exception("Invalid base64 string")
+ if remainder == 0:
+ return cleaned
+ return cleaned + "=" * (4 - remainder)
+
+
+def decodeBase64ToBytes(value):
+ normalized = normalizeBase64Input(value)
+ if not normalized:
+ return []
+ decoded = base64.b64decode(normalized)
+ return list(decoded)
+
+
+def encodeBytesToBase64(byte_list):
+ raw = bytes((b & 0xFF) for b in byte_list)
+ return base64.b64encode(raw).decode("ascii")
+
+
+def parseOffset(value):
+ trimmed = str(value or "").strip().lower()
+ if not trimmed:
+ raise Exception("offset 不能为空。")
+
+ if re.match(r"^0x[0-9a-f]+$", trimmed):
+ parsed = int(trimmed[2:], 16)
+ elif re.match(r"^\d+$", trimmed):
+ parsed = int(trimmed, 10)
+ else:
+ raise Exception("offset 必须是十进制数字或 0x 开头的十六进制。")
+
+ if not _is_finite(parsed) or parsed < 0:
+ raise Exception("offset 必须是大于等于 0 的整数。")
+ return parsed
+
+
+def clampPositiveInteger(value, defaultValue, maxValue, fieldName):
+ actual = defaultValue if value is None else int(value)
+ if not _is_finite(actual) or actual <= 0:
+ raise Exception(f"{fieldName} 必须是大于 0 的整数。")
+ return min(actual, maxValue)
+
+
+def normalizeHexBytesInput(value):
+ trimmed = str(value or "").strip()
+ if not trimmed:
+ raise Exception("hex_bytes 不能为空。")
+ withoutPrefix = re.sub(r"^0x", "", trimmed, flags=re.IGNORECASE)
+ normalized = re.sub(r"[^0-9a-fA-F]", "", withoutPrefix).lower()
+ if not normalized:
+ raise Exception("hex_bytes 中没有可用的十六进制字符。")
+ if len(normalized) % 2 != 0:
+ raise Exception("hex_bytes 必须包含偶数个十六进制字符。")
+ return normalized
+
+
+def parseHexBytes(value):
+ normalized = normalizeHexBytesInput(value)
+ bytes_list = []
+ for i in range(0, len(normalized), 2):
+ bytes_list.append(int(normalized[i:i + 2], 16))
+ return bytes_list
+
+
+def toHex(value, width):
+ hex_str = format(max(0, int(value or 0)), "x").upper()
+ while len(hex_str) < width:
+ hex_str = "0" + hex_str
+ return hex_str
+
+
+def bytesToHexString(bytes_list):
+ return " ".join(toHex(b, 2) for b in bytes_list)
+
+
+def bytesToAscii(bytes_list):
+ return "".join(chr(b) if 32 <= b <= 126 else "." for b in bytes_list)
+
+
+def buildHexDump(bytes_list, startOffset):
+ if len(bytes_list) == 0:
+ return ""
+
+ lines = []
+ offsetWidth = max(8, len(format(startOffset + len(bytes_list), "x")))
+
+ for index in range(0, len(bytes_list), BYTES_PER_LINE):
+ slice_ = bytes_list[index:index + BYTES_PER_LINE]
+ hexCells = [toHex(b, 2) for b in slice_]
+ while len(hexCells) < BYTES_PER_LINE:
+ hexCells.append(" ")
+ left = " ".join(hexCells[:8])
+ right = " ".join(hexCells[8:])
+ ascii_str = bytesToAscii(slice_).ljust(BYTES_PER_LINE, " ")
+ lines.append(f"{toHex(startOffset + index, offsetWidth)} {left} {right} |{ascii_str}|")
+
+ return "\n".join(lines)
+
+
+async def ensureFileExists(path, environment):
+ exists = await Tools.Files.exists(path, environment)
+ if not exists.get("exists"):
+ raise Exception(f"文件不存在: {path}")
+ if exists.get("isDirectory"):
+ raise Exception(f"目标路径是目录,不是文件: {path}")
+
+
+async def readFileBytes(path, environment):
+ await ensureFileExists(path, environment)
+ result = await Tools.Files.readBinary(path, environment)
+ base64_str = str(result.get("contentBase64") or "")
+ return {
+ "base64": base64_str,
+ "bytes": decodeBase64ToBytes(base64_str),
+ "size": int(result.get("size") or 0)
+ }
+
+
+async def writeFileBytes(path, bytes_list, environment):
+ return await Tools.Files.writeBinary(path, encodeBytesToBase64(bytes_list), environment)
+
+
+def collectWindow(bytes_list, offset, length):
+ if offset > len(bytes_list):
+ raise Exception(f"offset 超出文件范围。文件大小为 {len(bytes_list)} 字节。")
+ end = min(len(bytes_list), offset + length)
+ window = bytes_list[offset:end]
+ return {
+ "window": window,
+ "actualLength": len(window)
+ }
+
+
+async def read_hex_window(params):
+ environment = normalizeEnvironment(params.get("environment"))
+ offset = parseOffset(params.get("offset"))
+ length = clampPositiveInteger(params.get("length"), DEFAULT_WINDOW_LENGTH, MAX_WINDOW_LENGTH, "length")
+ file = await readFileBytes(params["file_path"], environment)
+ collected = collectWindow(file["bytes"], offset, length)
+ window = collected["window"]
+ actualLength = collected["actualLength"]
+
+ return {
+ "file_path": params["file_path"],
+ "environment": environment,
+ "file_size": file["size"],
+ "offset_decimal": offset,
+ "offset_hex": f"0x{toHex(offset, 8)}",
+ "requested_length": length,
+ "actual_length": actualLength,
+ "end_offset_exclusive_decimal": offset + actualLength,
+ "end_offset_exclusive_hex": f"0x{toHex(offset + actualLength, 8)}",
+ "hex": bytesToHexString(window),
+ "ascii_preview": bytesToAscii(window),
+ "hex_dump": buildHexDump(window, offset)
+ }
+
+
+async def write_hex_bytes(params):
+ environment = normalizeEnvironment(params.get("environment"))
+ offset = parseOffset(params.get("offset"))
+ patchBytes = parseHexBytes(params.get("hex_bytes"))
+ file = await readFileBytes(params["file_path"], environment)
+
+ if offset + len(patchBytes) > len(file["bytes"]):
+ raise Exception(f"写入范围超出文件大小。文件大小 {len(file['bytes'])} 字节,尝试写入到 {offset + len(patchBytes)}。")
+
+ beforeBytes = file["bytes"][offset:offset + len(patchBytes)]
+ for i in range(len(patchBytes)):
+ file["bytes"][offset + i] = patchBytes[i]
+
+ writeResult = await writeFileBytes(params["file_path"], file["bytes"], environment)
+ afterBytes = file["bytes"][offset:offset + len(patchBytes)]
+
+ return {
+ "file_path": params["file_path"],
+ "environment": environment,
+ "offset_decimal": offset,
+ "offset_hex": f"0x{toHex(offset, 8)}",
+ "bytes_written": len(patchBytes),
+ "before_hex": bytesToHexString(beforeBytes),
+ "after_hex": bytesToHexString(afterBytes),
+ "write_result": writeResult
+ }
+
+
+async def find_hex_pattern(params):
+ environment = normalizeEnvironment(params.get("environment"))
+ patternBytes = parseHexBytes(params.get("hex_bytes"))
+ maxResults = clampPositiveInteger(params.get("max_results"), DEFAULT_MAX_RESULTS, MAX_RESULTS_LIMIT, "max_results")
+ file = await readFileBytes(params["file_path"], environment)
+ matches = []
+
+ if len(patternBytes) > len(file["bytes"]):
+ return {
+ "file_path": params["file_path"],
+ "environment": environment,
+ "file_size": file["size"],
+ "pattern_hex": bytesToHexString(patternBytes),
+ "match_count": 0,
+ "matches": matches
+ }
+
+ for offset in range(0, len(file["bytes"]) - len(patternBytes) + 1):
+ matched = True
+ for index in range(len(patternBytes)):
+ if file["bytes"][offset + index] != patternBytes[index]:
+ matched = False
+ break
+ if not matched:
+ continue
+
+ contextStart = max(0, offset - CONTEXT_BEFORE)
+ contextEnd = min(len(file["bytes"]), contextStart + CONTEXT_TOTAL)
+ contextBytes = file["bytes"][contextStart:contextEnd]
+
+ matches.append({
+ "offset_decimal": offset,
+ "offset_hex": f"0x{toHex(offset, 8)}",
+ "matched_hex": bytesToHexString(patternBytes),
+ "context_hex_dump": buildHexDump(contextBytes, contextStart)
+ })
+
+ if len(matches) >= maxResults:
+ break
+
+ return {
+ "file_path": params["file_path"],
+ "environment": environment,
+ "file_size": file["size"],
+ "pattern_hex": bytesToHexString(patternBytes),
+ "match_count": len(matches),
+ "matches": matches
+ }
+
+
+async def wrap(fn, params, successMessage, failureMessage):
+ try:
+ data = await fn(params)
+ complete({
+ "success": True,
+ "message": successMessage,
+ "data": data
+ })
+ except Exception as error:
+ console.error(f"[hex_editor] {fn.__name__} failed:", error)
+ complete({
+ "success": False,
+ "message": f"{failureMessage}: {str(error)}",
+ "error_stack": traceback.format_exc()
+ })
+
+
+async def _exported_read_hex_window(params):
+ await wrap(read_hex_window, params, "Hex 窗口读取成功。", "Hex 窗口读取失败")
+
+
+async def _exported_write_hex_bytes(params):
+ await wrap(write_hex_bytes, params, "Hex 字节写入成功。", "Hex 字节写入失败")
+
+
+async def _exported_find_hex_pattern(params):
+ await wrap(find_hex_pattern, params, "Hex 模式搜索成功。", "Hex 模式搜索失败")
+
+
+exports.read_hex_window = _exported_read_hex_window
+exports.write_hex_bytes = _exported_write_hex_bytes
+exports.find_hex_pattern = _exported_find_hex_pattern
diff --git a/operit_py/jmcomic.py b/operit_py/jmcomic.py
new file mode 100644
index 0000000..bf32bf7
--- /dev/null
+++ b/operit_py/jmcomic.py
@@ -0,0 +1,702 @@
+# METADATA
+# {
+# "name": "jmcomic_downloader",
+# "display_name": {"zh": "JMComic 下载器", "en": "JMComic Downloader"},
+# "description": {"zh": "提供JMComic漫画下载功能,支持搜索、获取信息和下载,包括对新漫画的图片反爬解码。", "en": "JMComic downloader: search comics, fetch details, and download albums. Includes anti-crawling image decoding for newer comics."},
+# "category": "Media",
+# "tools": [
+# {"name": "main", "description": {"zh": "运行一个内置的测试函数,以验证JMComic工具的基本功能(搜索和获取信息)是否正常工作。", "en": "Run a built-in test to verify basic JMComic functionality (search and info retrieval)."}, "parameters": []},
+# {"name": "search_comics", "description": {"zh": "搜索JMComic漫画", "en": "Search JMComic comics."}, "parameters": [
+# {"name": "query", "description": {"zh": "搜索关键词", "en": "Search keyword."}, "type": "string", "required": True},
+# {"name": "page", "description": {"zh": "页码 (默认: 1)", "en": "Page number (default: 1)."}, "type": "number", "required": False},
+# {"name": "order_by", "description": {"zh": "排序方式 (latest, view, picture, like, 默认: view)", "en": "Sort mode: latest/view/picture/like (default: view)."}, "type": "string", "required": False},
+# {"name": "time", "description": {"zh": "时间范围 (today, week, month, all, 默认: all)", "en": "Time range: today/week/month/all (default: all)."}, "type": "string", "required": False}
+# ]},
+# {"name": "get_album_info", "description": {"zh": "获取漫画(本子)的详细信息", "en": "Get detailed information for a comic album."}, "parameters": [
+# {"name": "album_id", "description": {"zh": "漫画ID", "en": "Album ID."}, "type": "string", "required": True}
+# ]},
+# {"name": "download_album", "description": {"zh": "下载指定ID的单本漫画,包含图片解码功能。", "en": "Download a single comic album by ID, including image decoding."}, "parameters": [
+# {"name": "album_id", "description": {"zh": "要下载的漫画ID", "en": "Album ID to download."}, "type": "string", "required": True},
+# {"name": "download_dir", "description": {"zh": "下载目录 (可选, 默认: /sdcard/Download/Operit/plugins/jmcomic_downloader/downloads) (Android-only 路径)", "en": "Download directory (optional)."}, "type": "string", "required": False}
+# ]},
+# {"name": "batch_download_albums", "description": {"zh": "批量下载多本漫画,包含图片解码功能。", "en": "Batch download multiple comic albums, including image decoding."}, "parameters": [
+# {"name": "album_ids", "description": {"zh": "要下载的漫画ID列表,用逗号分隔", "en": "Comma-separated list of album IDs to download."}, "type": "string", "required": True},
+# {"name": "download_dir", "description": {"zh": "下载目录 (可选)", "en": "Download directory (optional)."}, "type": "string", "required": False}
+# ]}
+# ],
+# "enabledByDefault": False
+# }
+
+import json
+import re
+import time
+import math
+import random
+import hashlib
+import base64
+import asyncio
+from urllib.parse import quote, unquote
+
+Error = Exception
+
+# AES 解密依赖(跨平台)
+try:
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+ from cryptography.hazmat.primitives import padding as _padding
+ _HAS_CRYPTO = True
+except ImportError:
+ _HAS_CRYPTO = False
+
+# 图片处理依赖(跨平台,替代 Jimp)
+try:
+ from PIL import Image
+ import io as _io
+ _HAS_PIL = True
+except ImportError:
+ _HAS_PIL = False
+
+DEFAULT_DOWNLOAD_DIR = f"{getPluginConfigDir('jmcomic_downloader')}/downloads"
+TEST_DOWNLOAD_DIR = f"{getPluginConfigDir('jmcomic_downloader')}/test_downloads"
+
+__version__ = '2.6.4-py-adapted'
+
+
+def shuffleDomains(domains):
+ shuffled = list(domains)
+ for i in range(len(shuffled) - 1, 0, -1):
+ j = int(random.random() * (i + 1))
+ shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
+ return shuffled
+
+
+JmMagicConstants = {
+ "APP_TOKEN_SECRET": '18comicAPP',
+ "APP_TOKEN_SECRET_2": '18comicAPPContent',
+ "APP_DATA_SECRET": '185Hcomic3PAPP7R',
+ "APP_VERSION": '1.8.0',
+ "SCRAMBLE_220980": 220980,
+ "SCRAMBLE_268850": 268850,
+ "SCRAMBLE_421926": 421926,
+}
+
+JmModuleConfig = {
+ "PROT": 'https://',
+ "DOMAIN_API_LIST": shuffleDomains([
+ 'www.cdnmhwscc.vip',
+ 'www.cdnplaystation6.club',
+ 'www.cdnplaystation6.org',
+ 'www.cdnuc.vip',
+ 'www.cdn-mspjmapiproxy.xyz',
+ ]),
+ "DOMAIN_IMAGE_LIST": shuffleDomains([
+ 'cdn-msp.jmapiproxy1.cc',
+ 'cdn-msp.jmapiproxy2.cc',
+ 'cdn-msp2.jmapiproxy2.cc',
+ 'cdn-msp3.jmapiproxy2.cc',
+ ]),
+ "APP_HEADERS_TEMPLATE": {
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7',
+ 'X-Requested-With': 'com.jiaohua_browser',
+ 'user-agent': 'Mozilla/5.0 (Linux; Android 9; V1938CT Build/PQ3A.190705.11211812; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/91.0.4472.114 Safari/537.36',
+ },
+ "APP_HEADERS_IMAGE": {
+ 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7',
+ 'X-Requested-With': 'com.jiaohua_browser',
+ },
+}
+
+
+def joinPath(*segments):
+ return re.sub(r'/+', '/', '/'.join(segments))
+
+
+def dirname(filePath):
+ lastSlashPos = filePath.rfind('/')
+ if lastSlashPos == -1:
+ return "."
+ if lastSlashPos == 0:
+ return "/"
+ return filePath[:lastSlashPos]
+
+
+def basename(filePath):
+ return filePath[filePath.rfind('/') + 1:]
+
+
+async def ensureDirExists(dirPath):
+ if not dirPath or dirPath == '/' or dirPath == '.':
+ return
+ dirExists = await Tools.Files.exists(dirPath)
+ if dirExists.exists:
+ return
+ parentDir = dirname(dirPath)
+ await ensureDirExists(parentDir)
+ dirStillNotExists = await Tools.Files.exists(dirPath)
+ if not dirStillNotExists.exists:
+ await Tools.Files.mkdir(dirPath)
+
+
+async def runTasksWithConcurrency(tasks, limit):
+ results = [None] * len(tasks)
+ currentIndex = [0]
+
+ async def runner():
+ while currentIndex[0] < len(tasks):
+ taskIndex = currentIndex[0]
+ currentIndex[0] += 1
+ if taskIndex < len(tasks):
+ try:
+ results[taskIndex] = await tasks[taskIndex]()
+ except Exception as e:
+ console.error(f"并发任务 {taskIndex} 执行失败: {str(e)}")
+ results[taskIndex] = e
+
+ numRunners = min(limit, len(tasks))
+ await asyncio.gather(*[runner() for _ in range(numRunners)])
+ return [r for r in results if not isinstance(r, Exception)]
+
+
+class JmImageTool:
+ @staticmethod
+ def getNum(scrambleId, photoId, imageName):
+ scrambleIdNum = int(str(scrambleId))
+ photoIdNum = int(str(photoId))
+
+ if photoIdNum < scrambleIdNum:
+ return 0
+ elif photoIdNum < JmMagicConstants["SCRAMBLE_268850"]:
+ return 10
+ else:
+ x = 10 if photoIdNum < JmMagicConstants["SCRAMBLE_421926"] else 8
+ imageNameWithoutExt = JmImageTool.getFileNameFromUrl(imageName, True)
+ s = f"{photoIdNum}{imageNameWithoutExt}"
+ hash_str = hashlib.md5(s.encode('utf-8')).hexdigest()
+ lastChar = ord(hash_str[-1])
+ num = lastChar % x
+ return (num * 2) + 2
+
+ @staticmethod
+ def getFileNameFromUrl(url, withoutExtension=True):
+ queryIndex = url.find('?')
+ if queryIndex != -1:
+ url = url[:queryIndex]
+ filename = basename(url)
+ if withoutExtension:
+ lastDotIndex = filename.rfind('.')
+ return filename[:lastDotIndex] if lastDotIndex != -1 else filename
+ return filename
+
+ @staticmethod
+ async def decodeAndSave(num, imageBase64, decodedSavePath):
+ if num == 0:
+ await Tools.Files.writeBinary(decodedSavePath, imageBase64)
+ return
+
+ if not _HAS_PIL:
+ console.warn("PIL/Pillow 不可用,跳过图片解码,保存原始图片。")
+ await Tools.Files.writeBinary(decodedSavePath, imageBase64)
+ return
+
+ try:
+ raw_bytes = base64.b64decode(imageBase64)
+ srcImage = Image.open(_io.BytesIO(raw_bytes))
+ w, h = srcImage.size
+ over = h % num
+ resultImage = Image.new(srcImage.mode, (w, h))
+
+ for i in range(num):
+ move = int(h / num)
+ ySrc = h - (move * (i + 1)) - over
+ yDst = move * i
+
+ if i == 0:
+ move += over
+ else:
+ yDst += over
+
+ if ySrc < 0 or move <= 0 or (ySrc + move > h):
+ continue
+
+ strip = srcImage.crop((0, ySrc, w, ySrc + move))
+ resultImage.paste(strip, (0, yDst))
+
+ buf = _io.BytesIO()
+ resultImage.save(buf, format='JPEG')
+ pureBase64 = base64.b64encode(buf.getvalue()).decode('ascii')
+ await Tools.Files.writeBinary(decodedSavePath, pureBase64)
+ except Exception as e:
+ console.error(f"图片解码失败,将保存原始图片: {str(e)}")
+ await Tools.Files.writeBinary(decodedSavePath, imageBase64)
+
+
+class JmCryptoTool:
+ @staticmethod
+ def md5hex(key):
+ return hashlib.md5(key.encode('utf-8')).hexdigest()
+
+ @staticmethod
+ def tokenAndTokenparam(ts, secret=None):
+ if secret is None:
+ secret = JmMagicConstants["APP_TOKEN_SECRET"]
+ tokenparam = f"{ts},{JmMagicConstants['APP_VERSION']}"
+ token = JmCryptoTool.md5hex(f"{ts}{secret}")
+ return [token, tokenparam]
+
+ @staticmethod
+ def decodeRespData(data, ts, secret=None):
+ if secret is None:
+ secret = JmMagicConstants["APP_DATA_SECRET"]
+ try:
+ keyHex = JmCryptoTool.md5hex(f"{ts}{secret}")
+ if not _HAS_CRYPTO:
+ raise Error("cryptography 库不可用,无法进行 AES 解密。")
+ key = bytes.fromhex(keyHex)
+ cipher = Cipher(algorithms.AES(key), modes.ECB())
+ decryptor = cipher.decryptor()
+ # data 是 base64 编码的密文
+ ciphertext = base64.b64decode(data)
+ decrypted_padded = decryptor.update(ciphertext) + decryptor.finalize()
+ # 移除 PKCS7 填充
+ unpadder = _padding.PKCS7(128).unpadder()
+ decrypted = unpadder.update(decrypted_padded) + unpadder.finalize()
+ decryptedText = decrypted.decode('utf-8')
+ if not decryptedText:
+ raise Error("AES decryption returned an empty result.")
+ return decryptedText
+ except Exception as error:
+ if isinstance(error, Error):
+ raise error
+ raise Error(f"AES Decryption failed. Original error: {str(error)}")
+
+
+class JmApiResp:
+ def __init__(self, resp, ts):
+ self.resp = resp
+ self.ts = ts
+
+ @property
+ def isSuccess(self):
+ return self.resp.isSuccessful()
+
+ @property
+ def json(self):
+ try:
+ return json.loads(self.resp.content)
+ except Exception as error:
+ raise Error(f"JSON解析失败: {str(error)}")
+
+ @property
+ def isSuccessful(self):
+ return self.isSuccess and self.json.get("code") == 200
+
+ @property
+ def encodedData(self):
+ return self.json.get("data")
+
+ @property
+ def decodedData(self):
+ return JmCryptoTool.decodeRespData(self.encodedData, self.ts)
+
+ @property
+ def resData(self):
+ if not self.isSuccessful:
+ raise Error(f"API请求失败: code={self.json.get('code')}")
+ decoded = self.decodedData
+ try:
+ if not isinstance(decoded, str) or not decoded:
+ raise Error(f"Cannot parse non-string or empty value. Type: {type(decoded).__name__}")
+ return json.loads(decoded)
+ except Exception as error:
+ if isinstance(error, Error):
+ raise error
+ preview = str(decoded or 'N/A')[:80]
+ raise Error(f"Failed to parse decrypted response. Error: {str(error)}.")
+
+ @property
+ def modelData(self):
+ return self.resData
+
+
+class DirRuleImpl:
+ def __init__(self, baseDir):
+ self.baseDir = baseDir
+
+ def decideImageSaveDir(self, album, photo):
+ return joinPath(self.baseDir, self.sanitize(album["title"]))
+
+ def decideAlbumRootDir(self, album):
+ return joinPath(self.baseDir, self.sanitize(album["title"]))
+
+ def sanitize(self, name):
+ return re.sub(r'[\\?%*:|"<>]', '_', name)
+
+
+class JmOptionImpl:
+ def __init__(self, baseDir=None):
+ if baseDir is None:
+ baseDir = DEFAULT_DOWNLOAD_DIR
+ self.dirRule = DirRuleImpl(baseDir)
+
+ @staticmethod
+ def default(baseDir=None):
+ return JmOptionImpl(baseDir)
+
+ def buildJmClient(self):
+ return JmApiClientImpl()
+
+
+class JmApiClientImpl:
+ def __init__(self):
+ self.domainList = JmModuleConfig["DOMAIN_API_LIST"]
+ self.retryTimes = 3
+ self.client = OkHttp.newClient()
+ self.API_ALBUM = '/album'
+ self.API_CHAPTER = '/chapter'
+ self.API_SEARCH = '/search'
+ self.API_CATEGORIES_FILTER = '/categories/filter'
+
+ async def getAlbumDetail(self, albumId):
+ resp = await self.reqApi(f"{self.API_ALBUM}?id={albumId}")
+ data = resp.resData
+ if not data or not data.get("name"):
+ raise Error(f"本子 {albumId} 不存在或数据无效")
+ return self.parseAlbumData(albumId, data)
+
+ async def getPhotoDetail(self, photoId):
+ resp = await self.reqApi(f"{self.API_CHAPTER}?id={photoId}")
+ data = resp.resData
+ if not data or not data.get("name"):
+ raise Error(f"章节 {photoId} 不存在或数据无效")
+ return self.parsePhotoData(photoId, data)
+
+ async def searchComics(self, params):
+ query = params.get("query")
+ page = params.get("page", 1)
+ order_by = params.get("order_by", "view")
+ time_range = params.get("time", "all")
+
+ orderMap = {"latest": "mr", "view": "mv", "picture": "mp", "like": "tf"}
+ timeMap = {"today": "t", "week": "w", "month": "m", "all": "a"}
+ apiParams = {
+ "search_query": query,
+ "page": page,
+ "o": orderMap.get(order_by.lower(), orderMap["view"]),
+ "t": timeMap.get(time_range.lower(), timeMap["all"]),
+ }
+ resp = await self.reqApi(f"{self.API_SEARCH}?{self.toUrlSearchParams(apiParams)}")
+ data = resp.resData
+ results = [
+ {"id": str(item.get("id") or item.get("album_id")), "title": item.get("name") or item.get("title")}
+ for item in (data.get("content") or [])
+ ]
+ return {
+ "search_params": params,
+ "results": results,
+ "total_results": len(results),
+ }
+
+ async def reqApi(self, url, method="GET", data=None):
+ ts = int(time.time())
+ for i in range(len(self.domainList)):
+ domain = self.domainList[i]
+ for retry in range(self.retryTimes):
+ try:
+ fullUrl = f"{JmModuleConfig['PROT']}{domain}{url}"
+ token, tokenparam = JmCryptoTool.tokenAndTokenparam(ts)
+ headers = dict(JmModuleConfig["APP_HEADERS_TEMPLATE"])
+ headers["token"] = token
+ headers["tokenparam"] = tokenparam
+
+ requestBuilder = self.client.newRequest().url(fullUrl).headers(headers)
+ if method == "POST":
+ requestBuilder.method("POST").jsonBody(data)
+
+ resp = await requestBuilder.build().execute()
+
+ if resp.isSuccessful():
+ return JmApiResp(resp, ts)
+ except Exception as error:
+ console.log(f"[API] 请求失败: {str(error)} 域名: {domain}")
+ if retry == self.retryTimes - 1 and i == len(self.domainList) - 1:
+ raise Error(f"所有域名和重试都失败: {str(error)}")
+ raise Error("请求失败")
+
+ async def downloadImage(self, imageUrl, savePath, scrambleId, photoId):
+ try:
+ response = await self.client.newRequest().url(imageUrl).headers(JmModuleConfig["APP_HEADERS_IMAGE"]).build().execute()
+ if not response.isSuccessful():
+ raise Error(f"HTTP error! status: {response.statusCode}")
+ imageBase64 = response.bodyAsBase64()
+ dir_ = dirname(savePath)
+ await ensureDirExists(dir_)
+
+ imageName = JmImageTool.getFileNameFromUrl(imageUrl, False)
+ num = JmImageTool.getNum(scrambleId, photoId, imageName)
+ await JmImageTool.decodeAndSave(num, imageBase64, savePath)
+ return True
+ except Exception as error:
+ console.error(f"[图片] 下载失败: {imageUrl}, 错误: {str(error)}")
+ return False
+
+ def toUrlSearchParams(self, obj):
+ return '&'.join([f"{quote(str(k))}={quote(str(obj[k]))}" for k in obj])
+
+ def parseAlbumData(self, albumId, data):
+ episodeList = data.get("series") if data.get("series") and len(data.get("series")) > 0 else [{"id": albumId, "title": data.get("name")}]
+ return {
+ "id": albumId,
+ "title": data.get("name") or f"本子 {albumId}",
+ "author": (data.get("author") and data["author"][0]) or "未知作者",
+ "episodeList": episodeList,
+ "scrambleId": data.get("scramble_id") or JmMagicConstants["SCRAMBLE_220980"],
+ "length": len(episodeList),
+ }
+
+ def parsePhotoData(self, photoId, data):
+ return {
+ "id": photoId,
+ "title": data.get("name") or f"章节 {photoId}",
+ "pageArr": data.get("images") or [],
+ "albumId": data.get("album_id") or photoId,
+ "scrambleId": data.get("scramble_id") or JmMagicConstants["SCRAMBLE_220980"],
+ "length": len(data.get("images") or []),
+ }
+
+
+class JmDownloaderImpl:
+ def __init__(self, option):
+ self.option = option
+ self.client = option.buildJmClient()
+
+ async def downloadAlbum(self, albumId):
+ album = await self.client.getAlbumDetail(albumId)
+ await self.downloadByAlbumDetail(album)
+ return album
+
+ async def downloadByAlbumDetail(self, album):
+ albumDir = self.option.dirRule.decideAlbumRootDir(album)
+ await ensureDirExists(albumDir)
+ console.log(f"[专辑: {album['title']}] 发现 {album['episodeList'].__len__()} 个章节, 开始下载...")
+
+ chapterConcurrency = 5
+
+ def make_task(episode, i):
+ async def _task():
+ console.log(f" [章节 {i + 1}/{len(album['episodeList'])}] 开始下载: {episode['title']} ({episode['id']})")
+ try:
+ photo = await self.client.getPhotoDetail(episode["id"])
+ await self.downloadPhotoImages(photo, albumDir, album["id"])
+ console.log(f" [章节 {i + 1}/{len(album['episodeList'])}] 下载完成: {episode['title']}")
+ except Exception as e:
+ console.error(f" [章节 {i + 1}/{len(album['episodeList'])}] 下载失败: {episode['title']}, 错误: {str(e)}")
+ return _task
+
+ tasks = [make_task(episode, i) for i, episode in enumerate(album["episodeList"])]
+ await runTasksWithConcurrency(tasks, chapterConcurrency)
+
+ async def downloadPhotoImages(self, photo, albumDir, albumId):
+ if not photo["pageArr"] or len(photo["pageArr"]) == 0:
+ return
+
+ console.log(f" [图片集: {photo['title']}] 发现 {len(photo['pageArr'])} 张图片, 开始下载...")
+ concurrencyLimit = 10
+
+ def make_task(imageName, i):
+ async def _task():
+ finalFileName = f"{str(i + 1).zfill(5)}.jpg"
+ filePath = joinPath(albumDir, finalFileName)
+ fileExists = await Tools.Files.exists(filePath)
+ if fileExists.exists:
+ return
+ imageUrl = self.buildImageUrl(photo, imageName)
+ try:
+ await self.client.downloadImage(imageUrl, filePath, photo["scrambleId"], photo["id"])
+ except Exception as e:
+ console.error(f" [图片下载失败] {finalFileName} from {photo['title']}: {str(e)}")
+ return _task
+
+ tasks = [make_task(imageName, i) for i, imageName in enumerate(photo["pageArr"])]
+ await runTasksWithConcurrency(tasks, concurrencyLimit)
+ console.log(f" [图片集: {photo['title']}] 所有图片下载任务已处理。")
+
+ def buildImageUrl(self, photo, imageName):
+ domain = JmModuleConfig["DOMAIN_IMAGE_LIST"][int(random.random() * len(JmModuleConfig["DOMAIN_IMAGE_LIST"]))]
+ return f"{JmModuleConfig['PROT']}{domain}/media/photos/{photo['albumId']}/{imageName}"
+
+
+class SimpleJMDownloader:
+ def __init__(self, downloadDir=None):
+ if downloadDir is None:
+ downloadDir = DEFAULT_DOWNLOAD_DIR
+ self.option = JmOptionImpl.default(downloadDir)
+ self.downloader = JmDownloaderImpl(self.option)
+ self.client = self.option.buildJmClient()
+ console.log(f"JM下载器初始化成功, 下载目录: {self.option.dirRule.baseDir}")
+
+ async def searchComics(self, params):
+ console.log(f"搜索漫画: {params.get('query')}")
+ return await self.client.searchComics(params)
+
+ async def getAlbumInfo(self, albumId):
+ album = await self.client.getAlbumDetail(albumId)
+ return {
+ "id": album["id"],
+ "title": album["title"],
+ "author": album["author"],
+ "chapterCount": album["length"],
+ "success": True,
+ }
+
+ async def downloadAlbum(self, albumId):
+ console.log(f"获取本子信息: {albumId}")
+ try:
+ info = await self.getAlbumInfo(albumId)
+ if not info["success"]:
+ return {"success": False, "albumId": albumId, "error": "获取信息失败"}
+ console.log(f"开始下载本子: {info['title']}")
+ await self.downloader.downloadAlbum(albumId)
+ downloadedFiles = await self._checkDownloadedFiles(info["title"])
+ return {
+ "success": True,
+ "albumId": albumId,
+ "title": info["title"],
+ "downloadedFiles": downloadedFiles,
+ }
+ except Exception as error:
+ return {"success": False, "albumId": albumId, "error": str(error)}
+
+ async def batchDownload(self, albumIds):
+ results = []
+ console.log(f"开始批量下载 {len(albumIds)} 个本子")
+ concurrencyLimit = 3
+
+ def make_task(albumId, i):
+ async def _task():
+ console.log(f"\n[{i + 1}/{len(albumIds)}] 开始处理本子: {albumId}")
+ result = await self.downloadAlbum(albumId)
+ if result["success"]:
+ console.log(f"[{i + 1}/{len(albumIds)}] 下载成功: {result.get('title')}")
+ else:
+ console.log(f"[{i + 1}/{len(albumIds)}] 下载失败: {albumId}, {result.get('error') or 'Unknown error'}")
+ return result
+ return _task
+
+ tasks = [make_task(albumId, i) for i, albumId in enumerate(albumIds)]
+ return await runTasksWithConcurrency(tasks, concurrencyLimit)
+
+ async def _checkDownloadedFiles(self, title):
+ albumDir = self.option.dirRule.decideAlbumRootDir({"title": title})
+ dirExists = await Tools.Files.exists(albumDir)
+ if dirExists.exists:
+ listResult = await Tools.Files.list(albumDir)
+ entries = getattr(listResult, "entries", None) or []
+ files = [e.name for e in entries]
+ return {
+ "directory": albumDir,
+ "fileCount": len(files),
+ "files": files[:10],
+ }
+ return {"directory": None, "fileCount": 0, "files": []}
+
+
+async def main():
+ console.log("开始执行JMComic工具功能测试...")
+ downloader = SimpleJMDownloader(TEST_DOWNLOAD_DIR)
+ testQuery = "原神"
+
+ console.log(f'1. 测试搜索功能,关键词: "{testQuery}"')
+ searchResult = await downloader.searchComics({"query": testQuery})
+
+ if not searchResult or not searchResult.get("results") or len(searchResult["results"]) == 0:
+ raise Error(f'搜索测试失败: 未能找到关于 "{testQuery}" 的任何结果。')
+
+ console.log(f"搜索成功, 找到 {searchResult['total_results']} 个结果。")
+ firstAlbum = searchResult["results"][0]
+ console.log(f"2. 测试获取作品信息功能, 作品ID: {firstAlbum['id']} ({firstAlbum['title']})")
+
+ albumInfo = await downloader.getAlbumInfo(firstAlbum["id"])
+
+ if not albumInfo or not albumInfo["success"]:
+ raise Error(f"获取作品信息失败, ID: {firstAlbum['id']}")
+
+ console.log(f"作品信息获取成功:")
+ console.log(f" - 标题: {albumInfo['title']}")
+ console.log(f" - 作者: {albumInfo['author']}")
+ console.log(f" - 章节数: {albumInfo['chapterCount']}")
+
+ console.log(f"3. 测试下载功能, 作品ID: {firstAlbum['id']} ({firstAlbum['title']})")
+ downloadResult = await downloader.downloadAlbum(firstAlbum["id"])
+
+ if not downloadResult or not downloadResult["success"]:
+ raise Error(f"下载作品失败, ID: {firstAlbum['id']}")
+
+ console.log(f"下载成功:")
+ dl = downloadResult.get("downloadedFiles") or {}
+ console.log(f" - 保存目录: {dl.get('directory')}")
+ console.log(f" - 文件数量: {dl.get('fileCount')}")
+
+ summary = f"JMComic工具测试完成。成功搜索、获取信息并下载了作品《{albumInfo['title']}》。"
+ console.log(f"\n{summary}")
+ return summary
+
+
+async def search_comics(params):
+ downloader = SimpleJMDownloader()
+ return await downloader.searchComics(params)
+
+
+async def get_album_info(params):
+ downloader = SimpleJMDownloader()
+ return await downloader.getAlbumInfo(params.get("album_id"))
+
+
+async def download_album(params):
+ downloader = SimpleJMDownloader(params.get("download_dir"))
+ return await downloader.downloadAlbum(params.get("album_id"))
+
+
+async def batch_download_albums(params):
+ albumIds = [id_.strip() for id_ in params.get("album_ids", "").split(",") if id_.strip()]
+ if len(albumIds) == 0:
+ raise Error("album_ids不能为空")
+ downloader = SimpleJMDownloader(params.get("download_dir"))
+ return await downloader.batchDownload(albumIds)
+
+
+async def jmcomic_wrap(func, params, successMessage, failMessage):
+ try:
+ console.log(f"开始执行: {func.__name__}")
+ result = await func(params)
+ complete({"success": True, "message": successMessage, "data": result})
+ except Exception as error:
+ import traceback
+ console.error(f"{func.__name__} 执行失败: {str(error)}")
+ complete({"success": False, "message": f"{failMessage}: {str(error)}", "error_stack": traceback.format_exc()})
+
+
+async def _main_wrapper(params):
+ await jmcomic_wrap(main, params, '功能测试完成', '功能测试失败')
+
+
+async def _search_comics_wrapper(params):
+ await jmcomic_wrap(search_comics, params, '搜索完成', '搜索失败')
+
+
+async def _get_album_info_wrapper(params):
+ await jmcomic_wrap(get_album_info, params, '信息获取完成', '信息获取失败')
+
+
+async def _download_album_wrapper(params):
+ await jmcomic_wrap(download_album, params, '下载完成', '下载失败')
+
+
+async def _batch_download_albums_wrapper(params):
+ await jmcomic_wrap(batch_download_albums, params, '批量下载完成', '批量下载失败')
+
+
+exports.main = _main_wrapper
+exports.search_comics = _search_comics_wrapper
+exports.get_album_info = _get_album_info_wrapper
+exports.download_album = _download_album_wrapper
+exports.batch_download_albums = _batch_download_albums_wrapper
diff --git a/operit_py/minimax_draw.py b/operit_py/minimax_draw.py
new file mode 100644
index 0000000..57f20fa
--- /dev/null
+++ b/operit_py/minimax_draw.py
@@ -0,0 +1,597 @@
+# METADATA
+# {
+# "name": "minimax_draw",
+# "display_name": {
+# "zh": "MiniMax 绘图",
+# "en": "MiniMax Draw"
+# },
+# "description": {
+# "zh": "使用 MiniMax 官方图像生成接口 (/v1/image_generation) 生成图片,支持文生图和带参考图的生图;结果保存到本地 /sdcard/Download/Operit/plugins/draw/minimax_draw/draws/ 目录,并返回 Markdown 图片提示。",
+# "en": "Generate images with the official MiniMax image generation API (/v1/image_generation). Supports text-to-image and reference-image generation. Saves results to /sdcard/Download/Operit/plugins/draw/minimax_draw/draws/ and returns Markdown image hints."
+# },
+# "category": "Draw",
+# "env": [
+# { "name": "MINIMAX_API_KEY", "description": { "zh": "MiniMax API Key(必填)", "en": "MiniMax API key (required)" }, "required": true },
+# { "name": "MINIMAX_API_BASE_URL", "description": { "zh": "MiniMax API Base URL(可选;默认 https://api.minimaxi.com,国际站可改为 https://api.minimax.io)", "en": "MiniMax API base URL (optional; defaults to https://api.minimaxi.com, international users can use https://api.minimax.io)" }, "required": false },
+# { "name": "MINIMAX_IMAGE_MODEL", "description": { "zh": "默认图片模型(可选;未传 model 时使用,默认 image-01)", "en": "Default image model (optional; used when model is omitted, default image-01)" }, "required": false },
+# { "name": "BEEIMG_API_KEY", "description": { "zh": "BeeIMG API Key(可选;仅当 image_paths 传本地参考图路径时需要,用于先上传到公网图床)", "en": "BeeIMG API key (optional; only needed when image_paths is used so local reference images can be uploaded first)" }, "required": false }
+# ],
+# "tools": [
+# {
+# "name": "draw_image",
+# "description": { "zh": "根据提示词调用 MiniMax 官方绘图接口生成图片,支持文生图和参考图生图,保存到本地并返回 Markdown 图片提示。", "en": "Generate images with the official MiniMax image API using a prompt. Supports text-to-image and reference-image generation, saves locally, and returns Markdown image hints." },
+# "parameters": [
+# { "name": "prompt", "description": { "zh": "绘图提示词(中文或英文)", "en": "Image prompt (Chinese or English)" }, "type": "string", "required": true },
+# { "name": "model", "description": { "zh": "模型名称(可选;不传则使用环境变量 MINIMAX_IMAGE_MODEL,否则默认 image-01)", "en": "Model name (optional; falls back to MINIMAX_IMAGE_MODEL, then image-01)" }, "type": "string", "required": false },
+# { "name": "aspect_ratio", "description": { "zh": "输出比例,可选 1:1、16:9、4:3、3:2、2:3、3:4、9:16、21:9;21:9 仅 image-01 支持", "en": "Aspect ratio. Supported: 1:1, 16:9, 4:3, 3:2, 2:3, 3:4, 9:16, 21:9. 21:9 is only supported by image-01." }, "type": "string", "required": false },
+# { "name": "width", "description": { "zh": "输出宽度(可选;仅 image-01 支持;需与 height 一起传,范围 512-2048 且为 8 的倍数)", "en": "Output width (optional; image-01 only; must be provided together with height, range 512-2048 and multiple of 8)" }, "type": "number", "required": false },
+# { "name": "height", "description": { "zh": "输出高度(可选;仅 image-01 支持;需与 width 一起传,范围 512-2048 且为 8 的倍数)", "en": "Output height (optional; image-01 only; must be provided together with width, range 512-2048 and multiple of 8)" }, "type": "number", "required": false },
+# { "name": "n", "description": { "zh": "生成图片数量,范围 1-9,默认 1", "en": "Number of images to generate, range 1-9, default 1" }, "type": "number", "required": false },
+# { "name": "response_format", "description": { "zh": "返回格式,可选 url 或 base64;默认 base64(更适合直接保存到本地)", "en": "Response format: url or base64. Defaults to base64 for direct local saving." }, "type": "string", "required": false },
+# { "name": "prompt_optimizer", "description": { "zh": "是否启用提示词优化(可选)", "en": "Enable prompt optimizer (optional)" }, "type": "boolean", "required": false },
+# { "name": "aigc_watermark", "description": { "zh": "是否添加 AIGC 水印(可选)", "en": "Add AIGC watermark (optional)" }, "type": "boolean", "required": false },
+# { "name": "seed", "description": { "zh": "随机种子(可选)", "en": "Seed (optional)" }, "type": "number", "required": false },
+# { "name": "image_urls", "description": { "zh": "参考图公网 URL 数组(可选;图生图用)。支持字符串数组、JSON 字符串或逗号分隔字符串", "en": "Reference image public URL list (optional; for reference-image generation). Accepts string array, JSON string, or comma-separated string." }, "type": "array", "required": false },
+# { "name": "image_paths", "description": { "zh": "参考图本地路径数组(可选;会先上传到图床再提交给 MiniMax)。支持字符串数组、JSON 字符串或逗号分隔字符串", "en": "Reference local image path list (optional; local images are uploaded first before calling MiniMax). Accepts string array, JSON string, or comma-separated string." }, "type": "array", "required": false },
+# { "name": "file_name", "description": { "zh": "自定义本地文件名(不含扩展名)", "en": "Custom local output file name without extension" }, "type": "string", "required": false },
+# { "name": "api_base_url", "description": { "zh": "自定义 MiniMax API Base URL(可选)", "en": "Custom MiniMax API base URL (optional)" }, "type": "string", "required": false }
+# ]
+# }
+# ]
+# }
+
+import json
+import math
+import re
+import time
+import traceback
+
+HTTP_TIMEOUT_MS = 600000
+client = OkHttp.newBuilder().connectTimeout(HTTP_TIMEOUT_MS).readTimeout(HTTP_TIMEOUT_MS).writeTimeout(HTTP_TIMEOUT_MS).build()
+
+DEFAULT_API_BASE_URL = "https://api.minimaxi.com"
+DEFAULT_MODEL = "image-01"
+DEFAULT_RESPONSE_FORMAT = "base64"
+DEFAULT_IMAGE_COUNT = 1
+DEFAULT_BASE64_EXTENSION = "jpeg"
+SUPPORTED_ASPECT_RATIOS = ["1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9"]
+BEEIMG_UPLOAD_ENDPOINT = "https://beeimg.com/api/upload/file/json/"
+
+DRAW_ROOT_DIR = getPluginConfigDir("draw")
+STORAGE_DIR = f"{DRAW_ROOT_DIR}/minimax_draw"
+DRAWS_DIR = f"{STORAGE_DIR}/draws"
+
+
+def _is_finite(n):
+ return isinstance(n, (int, float)) and not isinstance(n, bool) and math.isfinite(n)
+
+
+def _is_integer(n):
+ return _is_finite(n) and n == int(n)
+
+
+def isRecord(value):
+ return isinstance(value, dict)
+
+
+def getErrorMessage(error):
+ if isinstance(error, Exception):
+ return str(error)
+ return str(error)
+
+
+def getApiKey():
+ apiKey = str(getEnv("MINIMAX_API_KEY") or "").strip()
+ if not apiKey:
+ raise Exception("MINIMAX_API_KEY 未配置,请先在环境变量中设置 MiniMax API Key。")
+ return apiKey
+
+
+def joinUrl(baseUrl, path):
+ baseStr = str(baseUrl or "")
+ normalizedBase = baseStr if baseStr.endswith("/") else f"{baseStr}/"
+ pathStr = str(path or "")
+ normalizedPath = pathStr[1:] if pathStr.startswith("/") else pathStr
+ return f"{normalizedBase}{normalizedPath}"
+
+
+def getApiBaseUrl(customBaseUrl=None):
+ fromParam = str(customBaseUrl or "").strip()
+ if fromParam:
+ return fromParam
+
+ fromEnv = str(getEnv("MINIMAX_API_BASE_URL") or "").strip()
+ if fromEnv:
+ return fromEnv
+
+ return DEFAULT_API_BASE_URL
+
+
+def getImageEndpoint(baseUrl):
+ trimmed = str(baseUrl or "").strip()
+ if not trimmed:
+ return joinUrl(DEFAULT_API_BASE_URL, "v1/image_generation")
+ if "/v1/image_generation" in trimmed:
+ return trimmed
+ if trimmed.endswith("/v1"):
+ return joinUrl(trimmed, "image_generation")
+ if trimmed.endswith("/v1/"):
+ return joinUrl(trimmed, "image_generation")
+ return joinUrl(trimmed, "v1/image_generation")
+
+
+def resolveModel(model):
+ fromParam = str(model or "").strip()
+ if fromParam:
+ return fromParam
+
+ fromEnv = str(getEnv("MINIMAX_IMAGE_MODEL") or "").strip()
+ if fromEnv:
+ return fromEnv
+
+ return DEFAULT_MODEL
+
+
+def sanitizeFileName(name, fallbackPrefix):
+ safe = re.sub(r'[\\/:*?"<>|]', "_", str(name or "")).strip()
+ if not safe:
+ return f"{fallbackPrefix}_{int(time.time() * 1000)}"
+ return safe[:80]
+
+
+def buildFileBaseName(prompt, customName, fallbackPrefix):
+ fromCustom = str(customName or "").strip()
+ if fromCustom:
+ return sanitizeFileName(fromCustom, fallbackPrefix)
+ rawPrompt = str(prompt or "").strip()
+ shortPrompt = f"{rawPrompt[:40]}..." if len(rawPrompt) > 40 else rawPrompt
+ return f"{sanitizeFileName(shortPrompt or fallbackPrefix, fallbackPrefix)}_{int(time.time() * 1000)}"
+
+
+async def ensureDirectories():
+ dirs = [DRAW_ROOT_DIR, STORAGE_DIR, DRAWS_DIR]
+ for dir_ in dirs:
+ try:
+ result = await Tools.Files.mkdir(dir_)
+ if not result.get("successful"):
+ console.warn(f"创建目录失败(可能已存在): {dir_} -> {result.get('details')}")
+ except Exception as error:
+ console.warn(f"创建目录异常: {dir_} -> {getErrorMessage(error)}")
+
+
+def parsePositiveInteger(value, fieldName, options=None):
+ hasDefault = options is not None and "defaultValue" in options
+ defaultValue = options["defaultValue"] if hasDefault else None
+
+ if value is None or value == "":
+ return defaultValue
+
+ numberValue = value if isinstance(value, (int, float)) and not isinstance(value, bool) else float(str(value))
+ if not _is_finite(numberValue) or numberValue <= 0 or not _is_integer(numberValue):
+ raise Exception(f"{fieldName} 必须是正整数。")
+ numberValue = int(numberValue)
+
+ if options and options.get("min") is not None and numberValue < options["min"]:
+ raise Exception(f"{fieldName} 不能小于 {options['min']}。")
+
+ if options and options.get("max") is not None and numberValue > options["max"]:
+ raise Exception(f"{fieldName} 不能大于 {options['max']}。")
+
+ return numberValue
+
+
+def parseInteger(value, fieldName):
+ if value is None or value == "":
+ return None
+
+ numberValue = value if isinstance(value, (int, float)) and not isinstance(value, bool) else float(str(value))
+ if not _is_finite(numberValue) or not _is_integer(numberValue):
+ raise Exception(f"{fieldName} 必须是整数。")
+
+ return int(numberValue)
+
+
+def parseBoolean(value, fieldName):
+ if value is None or value == "":
+ return None
+
+ if isinstance(value, bool):
+ return value
+
+ normalized = str(value).strip().lower()
+ if normalized in ("true", "1", "yes"):
+ return True
+ if normalized in ("false", "0", "no"):
+ return False
+
+ raise Exception(f"{fieldName} 必须是布尔值。")
+
+
+def normalizeResponseFormat(value):
+ normalized = str(value or DEFAULT_RESPONSE_FORMAT).strip().lower()
+ if normalized != "url" and normalized != "base64":
+ raise Exception("response_format 仅支持 url 或 base64。")
+ return normalized
+
+
+def normalizeAspectRatio(model, value):
+ normalized = str(value or "").strip()
+ if not normalized:
+ return None
+
+ if normalized not in SUPPORTED_ASPECT_RATIOS:
+ raise Exception(f"aspect_ratio 仅支持 {'、'.join(SUPPORTED_ASPECT_RATIOS)}。")
+
+ if normalized == "21:9" and str(model or "").strip() != "image-01":
+ raise Exception("aspect_ratio=21:9 仅支持 model=image-01。")
+
+ return normalized
+
+
+def normalizeDimensions(model, widthValue, heightValue, aspectRatio):
+ if aspectRatio:
+ return {"width": None, "height": None}
+
+ width = parsePositiveInteger(widthValue, "width")
+ height = parsePositiveInteger(heightValue, "height")
+
+ if width is None and height is None:
+ return {"width": None, "height": None}
+
+ if width is None or height is None:
+ raise Exception("width 和 height 需要同时传入。")
+
+ if str(model or "").strip() != "image-01":
+ raise Exception("width 和 height 仅支持 model=image-01。")
+
+ if width < 512 or width > 2048 or width % 8 != 0:
+ raise Exception("width 需要在 512-2048 之间,且必须为 8 的倍数。")
+
+ if height < 512 or height > 2048 or height % 8 != 0:
+ raise Exception("height 需要在 512-2048 之间,且必须为 8 的倍数。")
+
+ return {"width": width, "height": height}
+
+
+def parseStringList(value, fieldName):
+ if value is None or value == "":
+ return []
+
+ if isinstance(value, list):
+ return [str(item or "").strip() for item in value if str(item or "").strip()]
+
+ if isinstance(value, str):
+ trimmed = value.strip()
+ if not trimmed:
+ return []
+
+ try:
+ parsed = json.loads(trimmed)
+ if isinstance(parsed, list):
+ return [str(item or "").strip() for item in parsed if str(item or "").strip()]
+ except Exception:
+ # ignore and try comma-separated parsing
+ pass
+
+ return [item.strip() for item in trimmed.split(",") if item.strip()]
+
+ raise Exception(f"{fieldName} 必须是字符串数组、JSON 字符串或逗号分隔字符串。")
+
+
+def isProbablyUrl(value):
+ return bool(re.match(r"^https?://", str(value or "").strip(), re.IGNORECASE))
+
+
+def getBeeimgApiKey():
+ return str(getEnv("BEEIMG_API_KEY") or "").strip()
+
+
+def guessMimeTypeFromPath(filePath):
+ lower = str(filePath or "").lower()
+ if lower.endswith(".png"):
+ return "image/png"
+ if lower.endswith(".jpg") or lower.endswith(".jpeg"):
+ return "image/jpeg"
+ if lower.endswith(".webp"):
+ return "image/webp"
+ if lower.endswith(".gif"):
+ return "image/gif"
+ return "application/octet-stream"
+
+
+async def uploadImageToBeeimg(filePath):
+ exists = await Tools.Files.exists(filePath)
+ if not exists.get("exists"):
+ raise Exception(f"参考图文件不存在: {filePath}")
+
+ apiKey = getBeeimgApiKey()
+ if not apiKey:
+ raise Exception("使用 image_paths 需要配置 BEEIMG_API_KEY,用于先把本地参考图上传到公网图床。")
+
+ response = await Tools.Net.uploadFile({
+ "url": BEEIMG_UPLOAD_ENDPOINT,
+ "method": "POST",
+ "form_data": {
+ "apikey": apiKey
+ },
+ "files": [
+ {
+ "field_name": "file",
+ "file_path": filePath,
+ "content_type": guessMimeTypeFromPath(filePath)
+ }
+ ]
+ })
+
+ if response.statusCode < 200 or response.statusCode >= 300:
+ raise Exception(f"BeeIMG 上传失败: HTTP {response.statusCode} - {response.content}")
+
+ try:
+ parsed = json.loads(response.content)
+ except Exception as error:
+ raise Exception(f"解析 BeeIMG 响应失败: {getErrorMessage(error)}")
+
+ files = parsed.get("files") if (isRecord(parsed) and isRecord(parsed.get("files"))) else None
+ fileUrl = str(files.get("url")).strip() if (files and files.get("url")) else ""
+ code = str(files.get("code")) if (files and files.get("code") is not None) else ""
+ status = str(files.get("status")) if (files and files.get("status")) else ""
+
+ if not fileUrl or (code != "200" and code != "" and status != "Success"):
+ raise Exception(f"BeeIMG 上传失败: {response.content}")
+
+ return fileUrl
+
+
+async def resolveSubjectReference(imageUrls, imagePaths):
+ resolvedUrls = parseStringList(imageUrls, "image_urls")
+ resolvedPaths = parseStringList(imagePaths, "image_paths")
+
+ for url in resolvedUrls:
+ if not isProbablyUrl(url):
+ raise Exception(f"image_urls 中包含无效链接: {url}")
+
+ for filePath in resolvedPaths:
+ uploadedUrl = await uploadImageToBeeimg(filePath)
+ resolvedUrls.append(uploadedUrl)
+
+ if len(resolvedUrls) == 0:
+ return None
+
+ return [{"type": "character", "image_file": url} for url in resolvedUrls]
+
+
+def normalizeBase64(base64):
+ raw = str(base64 or "").strip()
+ if not raw:
+ return raw
+
+ prefixIndex = raw.find("base64,")
+ if raw.startswith("data:") and prefixIndex >= 0:
+ return raw[prefixIndex + len("base64,"):].strip()
+
+ return raw
+
+
+def guessExtensionFromUrl(url, fallbackExtension):
+ match = re.search(r"\.([a-z0-9]{2,5})(?:[?#].*)?$", str(url or ""), re.IGNORECASE)
+ if match and match.group(1):
+ return match.group(1).lower()
+ return fallbackExtension
+
+
+def parseMiniMaxError(parsed, fallbackMessage):
+ baseResp = parsed.get("base_resp") if (isRecord(parsed) and isRecord(parsed.get("base_resp"))) else None
+ if not baseResp:
+ return
+
+ statusCode = float(baseResp.get("status_code")) if baseResp.get("status_code") is not None else 0
+ if _is_finite(statusCode) and statusCode != 0:
+ statusMsg = str(baseResp.get("status_msg")) if baseResp.get("status_msg") else fallbackMessage
+ raise Exception(f"MiniMax 图片接口返回错误: {statusMsg} (status_code={int(statusCode) if _is_integer(statusCode) else statusCode})")
+
+
+async def callMiniMaxImageApi(params):
+ apiBaseUrl = getApiBaseUrl(params.get("api_base_url"))
+ endpoint = getImageEndpoint(apiBaseUrl)
+ effectiveModel = resolveModel(params.get("model"))
+ responseFormat = normalizeResponseFormat(params.get("response_format"))
+ aspectRatio = normalizeAspectRatio(effectiveModel, params.get("aspect_ratio"))
+ dimensions = normalizeDimensions(effectiveModel, params.get("width"), params.get("height"), aspectRatio)
+ imageCount = parsePositiveInteger(params.get("n"), "n", {"defaultValue": DEFAULT_IMAGE_COUNT, "min": 1, "max": 9})
+ promptOptimizer = parseBoolean(params.get("prompt_optimizer"), "prompt_optimizer")
+ aigcWatermark = parseBoolean(params.get("aigc_watermark"), "aigc_watermark")
+ seed = parseInteger(params.get("seed"), "seed")
+ subjectReference = await resolveSubjectReference(params.get("image_urls"), params.get("image_paths"))
+
+ body = {
+ "model": effectiveModel,
+ "prompt": str(params.get("prompt") or "").strip(),
+ "response_format": responseFormat,
+ "n": imageCount
+ }
+
+ if aspectRatio:
+ body["aspect_ratio"] = aspectRatio
+
+ if dimensions.get("width") is not None and dimensions.get("height") is not None:
+ body["width"] = dimensions["width"]
+ body["height"] = dimensions["height"]
+
+ if promptOptimizer is not None:
+ body["prompt_optimizer"] = promptOptimizer
+
+ if aigcWatermark is not None:
+ body["aigc_watermark"] = aigcWatermark
+
+ if seed is not None:
+ body["seed"] = seed
+
+ if subjectReference:
+ body["subject_reference"] = subjectReference
+
+ request = client.newRequest().url(endpoint).method("POST").headers({
+ "accept": "application/json",
+ "content-type": "application/json",
+ "Authorization": f"Bearer {getApiKey()}"
+ }).body(json.dumps(body), "json")
+
+ response = await request.build().execute()
+ if not response.isSuccessful():
+ raise Exception(f"MiniMax 图片 API 调用失败: {response.statusCode} - {response.content}")
+
+ try:
+ parsed = json.loads(response.content)
+ except Exception as error:
+ raise Exception(f"解析 MiniMax 响应失败: {getErrorMessage(error)}")
+
+ parseMiniMaxError(parsed, "MiniMax 接口返回错误")
+
+ data = parsed.get("data") if (isRecord(parsed) and isRecord(parsed.get("data"))) else None
+ if not data:
+ raise Exception("MiniMax 响应中未找到 data 对象。")
+
+ metadata = data.get("metadata") if isRecord(data.get("metadata")) else None
+ successCount = float(metadata.get("success_count")) if (metadata and metadata.get("success_count") is not None) else None
+ failedCount = float(metadata.get("failed_count")) if (metadata and metadata.get("failed_count") is not None) else None
+ requestId = str(parsed.get("id")) if (isRecord(parsed) and parsed.get("id")) else ""
+
+ if responseFormat == "base64":
+ imageBase64 = [str(item or "").strip() for item in data.get("image_base64")] if isinstance(data.get("image_base64"), list) else []
+ imageBase64 = [item for item in imageBase64 if len(item) > 0]
+
+ if len(imageBase64) == 0:
+ raise Exception("MiniMax 响应中未找到 data.image_base64。")
+
+ return {
+ "request_id": requestId,
+ "effective_model": effectiveModel,
+ "response_format": responseFormat,
+ "image_base64": imageBase64,
+ "image_urls": [],
+ "success_count": int(successCount) if _is_finite(successCount) else len(imageBase64),
+ "failed_count": int(failedCount) if _is_finite(failedCount) else 0,
+ "reference_count": len(subjectReference) if subjectReference else 0
+ }
+
+ imageUrls = [str(item or "").strip() for item in data.get("image_urls")] if isinstance(data.get("image_urls"), list) else []
+ imageUrls = [item for item in imageUrls if len(item) > 0]
+
+ if len(imageUrls) == 0:
+ raise Exception("MiniMax 响应中未找到 data.image_urls。")
+
+ return {
+ "request_id": requestId,
+ "effective_model": effectiveModel,
+ "response_format": responseFormat,
+ "image_base64": [],
+ "image_urls": imageUrls,
+ "success_count": int(successCount) if _is_finite(successCount) else len(imageUrls),
+ "failed_count": int(failedCount) if _is_finite(failedCount) else 0,
+ "reference_count": len(subjectReference) if subjectReference else 0
+ }
+
+
+async def draw_image(params):
+ prompt = str(params.get("prompt") if params and params.get("prompt") else "").strip()
+ if not prompt:
+ raise Exception("参数 prompt 不能为空。")
+
+ await ensureDirectories()
+
+ apiResult = await callMiniMaxImageApi({
+ "prompt": prompt,
+ "model": params.get("model"),
+ "aspect_ratio": params.get("aspect_ratio"),
+ "width": params.get("width"),
+ "height": params.get("height"),
+ "n": params.get("n"),
+ "response_format": params.get("response_format"),
+ "prompt_optimizer": params.get("prompt_optimizer"),
+ "aigc_watermark": params.get("aigc_watermark"),
+ "seed": params.get("seed"),
+ "image_urls": params.get("image_urls"),
+ "image_paths": params.get("image_paths"),
+ "api_base_url": params.get("api_base_url")
+ })
+
+ baseName = buildFileBaseName(prompt, params.get("file_name"), "minimax_draw")
+ files = []
+
+ if apiResult["response_format"] == "base64":
+ for index in range(len(apiResult["image_base64"])):
+ suffix = f"_{index + 1}" if len(apiResult["image_base64"]) > 1 else ""
+ filePath = f"{DRAWS_DIR}/{baseName}{suffix}.{DEFAULT_BASE64_EXTENSION}"
+ writeResult = await Tools.Files.writeBinary(filePath, normalizeBase64(apiResult["image_base64"][index]))
+ if not writeResult.get("successful"):
+ raise Exception(f"保存图片失败: {writeResult.get('details')}")
+ fileUri = f"file://{filePath}"
+ files.append({
+ "file_path": filePath,
+ "file_uri": fileUri,
+ "markdown": f"![MiniMax 生成的图片{f' {index + 1}' if len(apiResult['image_base64']) > 1 else ''}]({fileUri})",
+ "remote_image_url": None
+ })
+ else:
+ for index in range(len(apiResult["image_urls"])):
+ imageUrl = apiResult["image_urls"][index]
+ extension = guessExtensionFromUrl(imageUrl, "png")
+ suffix = f"_{index + 1}" if len(apiResult["image_urls"]) > 1 else ""
+ filePath = f"{DRAWS_DIR}/{baseName}{suffix}.{extension}"
+ downloadResult = await Tools.Files.download(imageUrl, filePath)
+ if not downloadResult.get("successful"):
+ raise Exception(f"下载图片失败: {downloadResult.get('details')}")
+ fileUri = f"file://{filePath}"
+ files.append({
+ "file_path": filePath,
+ "file_uri": fileUri,
+ "markdown": f"![MiniMax 生成的图片{f' {index + 1}' if len(apiResult['image_urls']) > 1 else ''}]({fileUri})",
+ "remote_image_url": imageUrl
+ })
+
+ hintLines = []
+ hintLines.append(f"图片已生成并保存在本地 {DRAWS_DIR}。")
+ hintLines.append(f"共生成 {len(files)} 张。")
+ if apiResult["reference_count"] > 0:
+ hintLines.append(f"本次使用了 {apiResult['reference_count']} 张参考图。")
+ hintLines.append("")
+ hintLines.append("后续回答如果需要展示图片,请直接输出下面这些 Markdown:")
+ hintLines.append("")
+ for fileItem in files:
+ hintLines.append(fileItem["markdown"])
+
+ return {
+ "prompt": prompt,
+ "model": apiResult["effective_model"],
+ "request_id": apiResult["request_id"] or None,
+ "response_format": apiResult["response_format"],
+ "success_count": apiResult["success_count"],
+ "failed_count": apiResult["failed_count"],
+ "reference_count": apiResult["reference_count"],
+ "file_path": files[0]["file_path"],
+ "file_uri": files[0]["file_uri"],
+ "markdown": files[0]["markdown"],
+ "files": files,
+ "hint": "\n".join(hintLines)
+ }
+
+
+async def draw_image_wrapper(params=None):
+ if params is None:
+ params = {}
+ try:
+ result = await draw_image(params)
+ complete({
+ "success": True,
+ "message": "MiniMax 图片生成成功,已保存到本地。",
+ "data": result
+ })
+ except Exception as error:
+ console.error("draw_image 执行失败:", error)
+ complete({
+ "success": False,
+ "message": f"MiniMax 图片生成失败: {getErrorMessage(error)}",
+ "error_stack": traceback.format_exc()
+ })
+
+
+exports.draw_image = draw_image_wrapper
diff --git a/operit_py/nanobanana_draw.py b/operit_py/nanobanana_draw.py
new file mode 100644
index 0000000..78d2623
--- /dev/null
+++ b/operit_py/nanobanana_draw.py
@@ -0,0 +1,636 @@
+# METADATA
+# {
+# "name": "nanobanana_draw",
+# "display_name": {"zh": "Nanobanana 绘图", "en": "Nanobanana Draw"},
+# "description": {"zh": "使用 Nano Banana API (基于Grsai的api服务/https://grsai.com/) 根据提示词画图,支持文生图和图生图(可传入参考图片URL或本地图片路径;本地图片会先上传到图床以获得公网URL),将图片保存到本地 /sdcard/Download/Operit/plugins/draw/nanobanana_draw/draws/ 目录,并返回 Markdown 图片提示。", "en": "Generate images using the Nano Banana API (via Grsai service / https://grsai.com/). Supports text-to-image and image-to-image (you can provide reference image URLs or local image paths; local images will be uploaded first to get public URLs). Saves images to /sdcard/Download/Operit/plugins/draw/nanobanana_draw/draws/ and returns a Markdown image reference."},
+# "env": ["NANOBANANA_API_KEY", "NANOBANANA_API_BASE_URL", "BEEIMG_API_KEY"],
+# "category": "Draw",
+# "tools": [
+# {"name": "draw_image", "description": {"zh": "根据提示词调用 Nano Banana API 生成图片(支持文生图和图生图),保存到本地并返回 Markdown 图片提示。", "en": "Generate an image via the Nano Banana API using a prompt (supports text-to-image and image-to-image), save locally, and return a Markdown image reference."}, "parameters": [
+# {"name": "prompt", "description": {"zh": "绘图提示词(英文或中文皆可)", "en": "Image prompt (Chinese or English)"}, "type": "string", "required": True},
+# {"name": "model", "description": {"zh": "Nano Banana 模型名称(优先级最高)。可填 nano-banana-pro(约1800积分)或 nano-banana(约400积分)", "en": "Nano Banana model name (highest priority). e.g. nano-banana-pro (~1800 credits) or nano-banana (~400 credits)."}, "type": "string", "required": False},
+# {"name": "model_variant", "description": {"zh": "模型档位(二选一,可选):pro(约1800积分)或 nano(约400积分)。未传时默认 pro", "en": "Model tier (optional): pro (~1800 credits) or nano (~400 credits). Defaults to pro."}, "type": "string", "required": False},
+# {"name": "aspect_ratio", "description": {"zh": "输出图像比例,如 '1:1', '16:9', 'auto' 等,可选", "en": "Output aspect ratio, e.g. '1:1', '16:9', 'auto' (optional)"}, "type": "string", "required": False},
+# {"name": "image_size", "description": {"zh": "输出图像大小,仅 nano-banana-pro 支持,如 '1K', '2K', '4K',可选", "en": "Output image size (only supported by nano-banana-pro), e.g. '1K', '2K', '4K' (optional)"}, "type": "string", "required": False},
+# {"name": "image_urls", "description": {"zh": "参考图URL数组(图生图),支持格式:字符串数组['https://...'] 或 JSON字符串'[\"https://...\"]' 或逗号分隔'url1,url2',可选", "en": "Reference image URL list for img2img. Accepts: string array ['https://...'], or JSON string '[\"https://...\"]', or comma-separated 'url1,url2' (optional)."}, "type": "array", "required": False},
+# {"name": "image_paths", "description": {"zh": "参考图本地路径数组(图生图,会先上传图床再进行生成),支持格式:字符串数组['/sdcard/...'] 或 JSON字符串 或 逗号分隔,可选", "en": "Reference local image path list for img2img (will be uploaded first). Accepts: string array ['/sdcard/...'], or JSON string, or comma-separated list (optional)."}, "type": "array", "required": False},
+# {"name": "file_name", "description": {"zh": "自定义保存到本地的文件名(不含路径和扩展名)", "en": "Custom output file name (without path or extension)"}, "type": "string", "required": False},
+# {"name": "poll_interval_ms", "description": {"zh": "轮询间隔(毫秒),默认 5000", "en": "Polling interval (milliseconds), default 5000"}, "type": "number", "required": False},
+# {"name": "max_wait_time_ms", "description": {"zh": "最长等待时间(毫秒)。默认 10 分钟", "en": "Max wait time (milliseconds). Default 10 minutes."}, "type": "number", "required": False}
+# ]}
+# ]
+# }
+
+import json
+import re
+import time
+import math
+from urllib.parse import quote, unquote
+
+Error = Exception
+
+HTTP_TIMEOUT_MS = 600000
+client = (
+ OkHttp.newBuilder()
+ .connectTimeout(HTTP_TIMEOUT_MS)
+ .readTimeout(HTTP_TIMEOUT_MS)
+ .writeTimeout(HTTP_TIMEOUT_MS)
+ .build()
+)
+
+BEEIMG_UPLOAD_ENDPOINT = "https://beeimg.com/api/upload/file/json/"
+
+# API配置
+DEFAULT_API_BASE_URL = "https://grsai.dakka.com.cn"
+DRAW_API_PATH = "v1/draw/nano-banana"
+RESULT_API_PATH = "v1/draw/result"
+MODEL_PRO = "nano-banana-pro"
+MODEL_NANO = "nano-banana"
+DEFAULT_MODEL = MODEL_PRO
+# Android 实际路径为 /sdcard/Download,对应系统中文名"下载" (Android-only)
+DRAW_ROOT_DIR = getPluginConfigDir("draw")
+STORAGE_DIR = f"{DRAW_ROOT_DIR}/nanobanana_draw"
+DRAWS_DIR = f"{STORAGE_DIR}/draws"
+
+# 轮询配置
+POLL_INTERVAL = 5000 # 每5秒查询一次
+MAX_WAIT_TIME = 600000 # 最多等待10分钟
+
+
+def isRecord(value):
+ return isinstance(value, dict)
+
+
+def getErrorMessage(error):
+ if isinstance(error, Exception):
+ return str(error)
+ return str(error)
+
+
+def getErrorStack(error):
+ if isinstance(error, Exception):
+ import traceback
+ return "".join(traceback.format_exception(type(error), error, error.__traceback__))
+ return None
+
+
+def resolveModel(model=None, modelVariant=None):
+ if model and model.strip():
+ return model.strip()
+
+ if not modelVariant or not modelVariant.strip():
+ return DEFAULT_MODEL
+
+ normalizedVariant = modelVariant.strip().lower()
+ if normalizedVariant == "pro":
+ return MODEL_PRO
+ if normalizedVariant == "nano":
+ return MODEL_NANO
+
+ raise Error("参数 model_variant 仅支持 'pro' 或 'nano'。")
+
+
+def normalizePositiveInt(value, fallback):
+ if value is None:
+ return fallback
+ n = value if isinstance(value, (int, float)) else int(str(value), 10)
+ if not math.isfinite(n) or n <= 0:
+ return fallback
+ return int(n)
+
+
+def getApiKey():
+ apiKey = getEnv("NANOBANANA_API_KEY")
+ if not apiKey:
+ raise Error("NANOBANANA_API_KEY 未配置,请在环境变量中设置 Nano Banana 的 API Key。")
+ return apiKey
+
+
+def getBeeimgApiKey():
+ return getEnv("BEEIMG_API_KEY") or ""
+
+
+def joinUrl(baseUrl, path):
+ normalizedBase = baseUrl if baseUrl.endswith("/") else f"{baseUrl}/"
+ normalizedPath = path[1:] if path.startswith("/") else path
+ return f"{normalizedBase}{normalizedPath}"
+
+
+def getApiBaseUrl():
+ fromEnv = str(getEnv("NANOBANANA_API_BASE_URL") or "").strip()
+ if fromEnv:
+ return fromEnv
+ return DEFAULT_API_BASE_URL
+
+
+def getDrawEndpoint(baseUrl):
+ trimmed = baseUrl.strip()
+ if not trimmed:
+ return joinUrl(DEFAULT_API_BASE_URL, DRAW_API_PATH)
+ if "/v1/draw/nano-banana" in trimmed:
+ return trimmed
+ if trimmed.endswith("/v1"):
+ return joinUrl(trimmed, "draw/nano-banana")
+ if trimmed.endswith("/v1/"):
+ return joinUrl(trimmed, "draw/nano-banana")
+ return joinUrl(trimmed, DRAW_API_PATH)
+
+
+def getResultEndpoint(baseUrl):
+ trimmed = baseUrl.strip()
+ if not trimmed:
+ return joinUrl(DEFAULT_API_BASE_URL, RESULT_API_PATH)
+ if "/v1/draw/result" in trimmed:
+ return trimmed
+ if "/v1/draw/nano-banana" in trimmed:
+ return trimmed.replace("/v1/draw/nano-banana", "/v1/draw/result")
+ if trimmed.endswith("/v1"):
+ return joinUrl(trimmed, "draw/result")
+ if trimmed.endswith("/v1/"):
+ return joinUrl(trimmed, "draw/result")
+ return joinUrl(trimmed, RESULT_API_PATH)
+
+
+def guessMimeTypeFromPath(filePath):
+ lower = filePath.lower()
+ if lower.endswith(".png"):
+ return "image/png"
+ if lower.endswith(".jpg") or lower.endswith(".jpeg"):
+ return "image/jpeg"
+ if lower.endswith(".webp"):
+ return "image/webp"
+ if lower.endswith(".gif"):
+ return "image/gif"
+ return "application/octet-stream"
+
+
+def safeJsonParseLoose(text):
+ trimmed = str(text or "").strip()
+ if not trimmed:
+ return None
+ try:
+ return json.loads(trimmed)
+ except Exception as e:
+ start = trimmed.find("{")
+ end = trimmed.rfind("}")
+ if start != -1 and end != -1 and end > start:
+ return json.loads(trimmed[start:end + 1])
+ raise e
+
+
+def isApiSuccessResponse(parsed):
+ code = parsed.get("code")
+ return code is None or code == 0 or code == "0"
+
+
+def extractTaskPayload(parsed):
+ if not isRecord(parsed):
+ return None
+ if isRecord(parsed.get("data")):
+ return parsed.get("data")
+ return parsed
+
+
+def extractMessage(parsed, fallback):
+ payload = extractTaskPayload(parsed)
+ candidates = [
+ parsed.get("msg"),
+ parsed.get("message"),
+ payload.get("failure_reason") if payload else None,
+ payload.get("error") if payload else None,
+ payload.get("msg") if payload else None,
+ payload.get("message") if payload else None,
+ ]
+
+ for candidate in candidates:
+ if (isinstance(candidate, str) or isinstance(candidate, (int, float))) and str(candidate).strip():
+ return str(candidate).strip()
+
+ return fallback
+
+
+def normalizeStatus(status):
+ return status.strip().lower() if isinstance(status, str) else ""
+
+
+def normalizeProgress(progress):
+ normalized = progress if isinstance(progress, (int, float)) else float(str(progress))
+ return normalized if math.isfinite(normalized) else 0
+
+
+def isSuccessStatus(status):
+ return status in ("succeeded", "success", "completed", "done")
+
+
+def isFailureStatus(status):
+ return status in ("failed", "error", "canceled", "cancelled")
+
+
+def extractImageUrlFromPayload(payload):
+ directCandidates = [
+ payload.get("url"),
+ payload.get("image_url"),
+ payload.get("imageUrl"),
+ payload.get("file_url"),
+ payload.get("fileUrl"),
+ payload.get("result_url"),
+ ]
+
+ for candidate in directCandidates:
+ if (isinstance(candidate, str) or isinstance(candidate, (int, float))) and str(candidate).strip():
+ return str(candidate).strip()
+
+ results = payload.get("results")
+ if not isinstance(results, list) or len(results) == 0:
+ return ""
+
+ first = results[0]
+ if isRecord(first):
+ nestedCandidates = [
+ first.get("url"),
+ first.get("image_url"),
+ first.get("imageUrl"),
+ first.get("file_url"),
+ first.get("fileUrl"),
+ first.get("result_url"),
+ ]
+ for candidate in nestedCandidates:
+ if (isinstance(candidate, str) or isinstance(candidate, (int, float))) and str(candidate).strip():
+ return str(candidate).strip()
+ elif (isinstance(first, str) or isinstance(first, (int, float))) and str(first).strip():
+ return str(first).strip()
+
+ return ""
+
+
+async def uploadImageToBeeimg(filePath):
+ exists = await Tools.Files.exists(filePath)
+ if not exists.exists:
+ raise Error(f"参考图文件不存在: {filePath}")
+
+ apiKey = getBeeimgApiKey()
+ if not apiKey:
+ raise Error("使用 image_paths 需要配置 BEEIMG_API_KEY(用于把本地图片上传到图床以获得公网URL)。")
+
+ resp = await Tools.Net.uploadFile({
+ "url": BEEIMG_UPLOAD_ENDPOINT,
+ "method": "POST",
+ "form_data": {"apikey": apiKey},
+ "files": [
+ {
+ "field_name": "file",
+ "file_path": filePath,
+ "content_type": guessMimeTypeFromPath(filePath),
+ }
+ ],
+ })
+
+ if resp.statusCode < 200 or resp.statusCode >= 300:
+ raise Error(f"BeeIMG 上传失败: HTTP {resp.statusCode} - {resp.content}")
+
+ parsed = None
+ try:
+ parsed = safeJsonParseLoose(resp.content)
+ except Exception as e:
+ raise Error(f"BeeIMG 上传响应解析失败: {getErrorMessage(e)}")
+
+ files = parsed.get("files") if isRecord(parsed) and isRecord(parsed.get("files")) else None
+ ok = bool(files) and (files.get("status") == "Success" or files.get("code") == "200" or files.get("code") == 200)
+ url = files.get("url") if files else None
+ if not ok or (not isinstance(url, str) and not isinstance(url, (int, float))) or not str(url).strip():
+ raise Error(f"BeeIMG 上传失败: {resp.content}")
+ return str(url)
+
+
+def sanitizeFileName(name):
+ safe = re.sub(r'[\\/:*?"<>|]', "_", name).strip()
+ if not safe:
+ return f"nano_draw_{int(time.time() * 1000)}"
+ return safe[:80]
+
+
+def buildFileName(prompt, customName=None):
+ if customName and customName.strip():
+ return sanitizeFileName(customName)
+ shortPrompt = f"{prompt[:40]}..." if len(prompt) > 40 else prompt
+ base = sanitizeFileName(shortPrompt or "image")
+ timestamp = int(time.time() * 1000)
+ return f"{base}_{timestamp}"
+
+
+async def ensureDirectories():
+ dirs = [DRAW_ROOT_DIR, STORAGE_DIR, DRAWS_DIR]
+ for dir_ in dirs:
+ try:
+ result = await Tools.Files.mkdir(dir_)
+ if not result.successful:
+ console.warn(f"创建目录失败(可能已存在): {dir_} -> {result.details}")
+ except Exception as e:
+ console.warn(f"创建目录异常: {dir_} -> {getErrorMessage(e)}")
+
+
+async def callNanobananaApi(params):
+ apiKey = getApiKey()
+ endpoint = getDrawEndpoint(getApiBaseUrl())
+
+ # 构建请求体 - 使用异步模式(webHook: "-1")
+ body = {
+ "model": params.get("model"),
+ "prompt": params.get("prompt"),
+ "webHook": "-1", # 关键:立即返回任务ID
+ "shutProgress": False,
+ }
+
+ # 添加可选参数
+ if params.get("aspect_ratio") and params.get("aspect_ratio").strip():
+ body["aspectRatio"] = params.get("aspect_ratio").strip()
+
+ if params.get("image_size") and params.get("image_size").strip():
+ body["imageSize"] = params.get("image_size").strip()
+
+ # 图生图:添加参考图URL数组
+ if params.get("image_urls") and isinstance(params.get("image_urls"), list) and len(params.get("image_urls")) > 0:
+ body["urls"] = [url for url in params.get("image_urls") if url and url.strip()]
+
+ headers = {
+ "accept": "application/json",
+ "content-type": "application/json",
+ "Authorization": f"Bearer {apiKey}",
+ }
+
+ request = (
+ client.newRequest()
+ .url(endpoint)
+ .method("POST")
+ .headers(headers)
+ .body(json.dumps(body), "json")
+ )
+
+ console.log("步骤1/2: 提交绘图任务...")
+ response = await request.build().execute()
+
+ if not response.isSuccessful():
+ raise Error(f"Nano Banana API 调用失败: {response.statusCode} - {response.content}")
+
+ parsed = None
+ try:
+ parsed = json.loads(response.content)
+ except Exception as e:
+ raise Error(f"解析 Nano Banana 响应失败: {getErrorMessage(e)}")
+
+ if not isRecord(parsed):
+ raise Error("API响应不是合法对象,请检查参数是否正确。响应: " + response.content)
+ if not isApiSuccessResponse(parsed):
+ raise Error(f"Nano Banana API 返回错误: {extractMessage(parsed, response.content)}")
+
+ payload = extractTaskPayload(parsed)
+ if not payload or not isinstance(payload.get("id"), str):
+ raise Error("API响应中未找到任务ID,请检查参数是否正确。响应: " + json.dumps(parsed))
+
+ taskId = payload.get("id")
+ console.log(f"任务提交成功! ID: {taskId}")
+ pollIntervalMs = params.get("poll_interval_ms") if params.get("poll_interval_ms") is not None else POLL_INTERVAL
+ maxWaitTimeMs = params.get("max_wait_time_ms") if params.get("max_wait_time_ms") is not None else MAX_WAIT_TIME
+ console.log(f"步骤2/2: 等待任务完成(轮询中,每{pollIntervalMs / 1000}秒查询一次,最长等待{math.ceil(maxWaitTimeMs / 60000)}分钟)...")
+
+ return taskId
+
+
+async def pollForResult(taskId, options=None):
+ options = options or {}
+ apiKey = getApiKey()
+ endpoint = getResultEndpoint(getApiBaseUrl())
+ pollIntervalMs = normalizePositiveInt(options.get("poll_interval_ms"), POLL_INTERVAL)
+ maxWaitTimeMs = normalizePositiveInt(options.get("max_wait_time_ms"), MAX_WAIT_TIME)
+ startTime = time.time() * 1000
+ attempt = 0
+
+ async def doSleep(ms):
+ await Tools.System.sleep(ms)
+
+ while time.time() * 1000 - startTime < maxWaitTimeMs:
+ attempt += 1
+ console.log(f"第{attempt}次查询任务状态...")
+
+ try:
+ request = (
+ client.newRequest()
+ .url(endpoint)
+ .method("POST")
+ .headers({
+ "accept": "application/json",
+ "content-type": "application/json",
+ "Authorization": f"Bearer {apiKey}",
+ })
+ .body(json.dumps({"id": taskId}), "json")
+ )
+
+ response = await request.build().execute()
+
+ if not response.isSuccessful():
+ console.warn(f"⚠️ 查询请求未成功 (HTTP {response.statusCode}): {response.content},将重试...")
+ await doSleep(pollIntervalMs)
+ continue
+
+ parsed = None
+ try:
+ parsed = json.loads(response.content)
+ except Exception:
+ console.warn("⚠️ 解析结果响应失败,将重试")
+ await doSleep(pollIntervalMs)
+ continue
+
+ if not isRecord(parsed):
+ console.warn(f"查询响应异常: {json.dumps(parsed)}")
+ await doSleep(pollIntervalMs)
+ continue
+
+ if parsed.get("code") == -22 or parsed.get("code") == "-22":
+ console.log("任务排队/处理中... (等待服务器生成)")
+ await doSleep(pollIntervalMs)
+ continue
+
+ if not isApiSuccessResponse(parsed):
+ console.warn(f"⚠️ API 返回异常状态,将重试: {json.dumps(parsed)}")
+ await doSleep(pollIntervalMs)
+ continue
+
+ data = extractTaskPayload(parsed)
+ if not data:
+ console.warn(f"查询响应异常 (无有效数据): {json.dumps(parsed)}")
+ await doSleep(pollIntervalMs)
+ continue
+
+ progress = normalizeProgress(data.get("progress"))
+ status = normalizeStatus(data.get("status"))
+ imageUrl = extractImageUrlFromPayload(data)
+
+ console.log(f"当前进度: {progress}% | 状态: {status or 'unknown'}")
+
+ if isSuccessStatus(status) or (progress >= 100 and len(imageUrl) > 0):
+ console.log("✅ 任务完成!")
+ if len(imageUrl) == 0:
+ raise Error("任务完成但响应中未找到图片URL: " + json.dumps(data))
+ return imageUrl
+ if isFailureStatus(status):
+ raise Error(f"任务执行失败: {json.dumps(data)}")
+ if (status == "running" or status == "processing") and progress > 0:
+ console.log(f"生成中... 进度: {progress}%")
+ except Exception as error:
+ console.log(f"⚠️ 第{attempt}次查询发生不可预知的异常: {getErrorMessage(error)},程序将自动进行下一次尝试...")
+
+ await doSleep(pollIntervalMs)
+
+ raise Error(f"任务超时: 等待超过{math.ceil(maxWaitTimeMs / 60000)}分钟仍未完成")
+
+
+def guessExtensionFromUrl(url):
+ match = re.search(r"\.(png|jpg|jpeg|webp|gif)(?:\?|#|$)", url, re.IGNORECASE)
+ if match and match.group(1):
+ return match.group(1).lower()
+ return "png"
+
+
+def parseImageUrls(image_urls):
+ # 如果已经是数组,直接过滤空值返回
+ if isinstance(image_urls, list):
+ return [url for url in image_urls if url and str(url).strip()]
+
+ # 如果是字符串,尝试解析
+ if isinstance(image_urls, str):
+ # 方法一:尝试JSON解析
+ try:
+ parsed = json.loads(image_urls)
+ if isinstance(parsed, list):
+ return [url for url in parsed if isinstance(url, str) and url.strip()]
+ except Exception:
+ pass
+
+ # 方法二:按逗号分割(支持 "url1,url2" 格式)
+ splitUrls = [url.strip() for url in image_urls.split(",")]
+ splitUrls = [url for url in splitUrls if url]
+ if splitUrls:
+ return splitUrls
+
+ return []
+
+
+def parseImagePaths(image_paths):
+ if isinstance(image_paths, list):
+ return [str(p).strip() for p in image_paths if p and str(p).strip()]
+ if isinstance(image_paths, str):
+ try:
+ parsed = json.loads(image_paths)
+ if isinstance(parsed, list):
+ return [str(p).strip() for p in parsed if p and str(p).strip()]
+ except Exception:
+ pass
+
+ splitPaths = [p.strip() for p in image_paths.split(",")]
+ splitPaths = [p for p in splitPaths if p]
+ if splitPaths:
+ return splitPaths
+ return []
+
+
+async def draw_image(params):
+ if not params or not params.get("prompt") or not str(params.get("prompt")).strip():
+ raise Error("参数 prompt 不能为空。")
+
+ prompt = str(params.get("prompt")).strip()
+ resolvedModel = resolveModel(params.get("model"), params.get("model_variant"))
+
+ if params.get("image_size") and params.get("image_size").strip() and resolvedModel != MODEL_PRO:
+ raise Error("参数 image_size 仅支持 pro 模型(model_variant='pro' 或 model='nano-banana-pro')。")
+
+ pollIntervalMs = normalizePositiveInt(params.get("poll_interval_ms"), POLL_INTERVAL)
+ normalizedImageSize = params.get("image_size").strip().upper() if params.get("image_size") else ""
+ defaultMaxWaitTimeMs = 600000 if normalizedImageSize == "4K" else MAX_WAIT_TIME
+ maxWaitTimeMs = normalizePositiveInt(params.get("max_wait_time_ms"), defaultMaxWaitTimeMs)
+
+ imageUrlsArray = []
+ if params.get("image_urls"):
+ imageUrlsArray = parseImageUrls(params.get("image_urls"))
+ if len(imageUrlsArray) == 0:
+ raise Error("参数 image_urls 必须是有效的URL数组。")
+
+ imagePathsArray = []
+ if params.get("image_paths"):
+ imagePathsArray = parseImagePaths(params.get("image_paths"))
+ if len(imagePathsArray) == 0:
+ raise Error("参数 image_paths 必须是有效的本地路径数组。")
+
+ if len(imagePathsArray) > 0:
+ console.log(f"检测到 {len(imagePathsArray)} 张本地参考图,开始上传以获得公网URL...")
+ for p in imagePathsArray:
+ url = await uploadImageToBeeimg(p)
+ imageUrlsArray.append(url)
+ console.log("本地参考图上传完成。")
+
+ await ensureDirectories()
+
+ # 步骤1: 提交任务并获取任务ID
+ taskId = await callNanobananaApi({
+ "prompt": prompt,
+ "model": resolvedModel,
+ "aspect_ratio": params.get("aspect_ratio"),
+ "image_size": params.get("image_size"),
+ "image_urls": imageUrlsArray,
+ "poll_interval_ms": pollIntervalMs,
+ "max_wait_time_ms": maxWaitTimeMs,
+ })
+
+ # 步骤2: 轮询等待任务完成
+ imageUrl = await pollForResult(taskId, {"poll_interval_ms": pollIntervalMs, "max_wait_time_ms": maxWaitTimeMs})
+
+ ext = guessExtensionFromUrl(imageUrl)
+ baseName = buildFileName(prompt, params.get("file_name"))
+ filePath = f"{DRAWS_DIR}/{baseName}.{ext}"
+
+ downloadResult = await Tools.Files.download(imageUrl, filePath)
+ if not downloadResult.successful:
+ raise Error(f"下载图片失败: {downloadResult.details}")
+
+ fileUri = f"file://{filePath}"
+ markdown = f""
+
+ hintLines = []
+ hintLines.append(f"图片已生成并保存在本地 {DRAWS_DIR}。")
+ hintLines.append(f"本地路径: {filePath}")
+ hintLines.append("")
+ hintLines.append("在后续回答中,请直接输出下面这一行 Markdown 来展示这张图片:")
+ hintLines.append("")
+ hintLines.append(markdown)
+
+ return {
+ "file_path": filePath,
+ "file_uri": fileUri,
+ "markdown": markdown,
+ "prompt": prompt,
+ "model": resolvedModel,
+ "aspect_ratio": params.get("aspect_ratio"),
+ "image_size": params.get("image_size"),
+ "image_urls": params.get("image_urls"),
+ "image_paths": params.get("image_paths"),
+ "hint": "\n".join(hintLines),
+ }
+
+
+async def draw_image_wrapper(params):
+ try:
+ result = await draw_image(params)
+ complete({
+ "success": True,
+ "message": f"图片生成成功,已保存到 {DRAWS_DIR},并返回 Markdown 图片提示。",
+ "data": result,
+ })
+ except Exception as error:
+ console.error("draw_image 执行失败:", error)
+ complete({
+ "success": False,
+ "message": f"图片生成失败: {getErrorMessage(error)}",
+ "error_stack": getErrorStack(error),
+ })
+
+
+exports.draw_image = draw_image_wrapper
diff --git a/operit_py/network_test.py b/operit_py/network_test.py
new file mode 100644
index 0000000..829b7e1
--- /dev/null
+++ b/operit_py/network_test.py
@@ -0,0 +1,538 @@
+# METADATA
+# {
+# "name": "network_test",
+# "display_name": {"zh": "网络测试", "en": "Network Test"},
+# "category": "Network",
+# "description": {"zh": "网络测试工具集合,提供基于OkHttp3的网络请求功能,包括GET、POST、PUT、DELETE请求方法,以及请求超时设置、重定向控制和拦截器管理。支持多种数据格式,便于测试API接口和网络连接性能。", "en": "Network testing tools based on OkHttp3. Provides GET/POST/PUT/DELETE requests, timeout settings, redirect control, and interceptor management. Supports multiple data formats for API testing and connectivity diagnostics."},
+# "tools": [
+# {"name": "http_get", "description": {"zh": "发送HTTP GET请求", "en": "Send an HTTP GET request."}, "parameters": [
+# {"name": "url", "description": {"zh": "请求URL", "en": "Request URL."}, "type": "string", "required": True},
+# {"name": "headers", "description": {"zh": "请求头", "en": "Request headers."}, "type": "object", "required": False}
+# ]},
+# {"name": "http_post", "description": {"zh": "发送HTTP POST请求", "en": "Send an HTTP POST request."}, "parameters": [
+# {"name": "url", "description": {"zh": "请求URL", "en": "Request URL."}, "type": "string", "required": True},
+# {"name": "body", "description": {"zh": "请求体", "en": "Request body."}, "type": "object", "required": True},
+# {"name": "headers", "description": {"zh": "请求头", "en": "Request headers."}, "type": "object", "required": False},
+# {"name": "body_type", "description": {"zh": "请求体类型,支持'text'、'json'、'form'、'multipart'", "en": "Body type: 'text', 'json', 'form', or 'multipart'."}, "type": "string", "required": False}
+# ]},
+# {"name": "http_put", "description": {"zh": "发送HTTP PUT请求", "en": "Send an HTTP PUT request."}, "parameters": [
+# {"name": "url", "description": {"zh": "请求URL", "en": "Request URL."}, "type": "string", "required": True},
+# {"name": "body", "description": {"zh": "请求体", "en": "Request body."}, "type": "object", "required": True},
+# {"name": "headers", "description": {"zh": "请求头", "en": "Request headers."}, "type": "object", "required": False},
+# {"name": "body_type", "description": {"zh": "请求体类型,支持'text'、'json'、'form'、'multipart'", "en": "Body type: 'text', 'json', 'form', or 'multipart'."}, "type": "string", "required": False}
+# ]},
+# {"name": "http_delete", "description": {"zh": "发送HTTP DELETE请求", "en": "Send an HTTP DELETE request."}, "parameters": [
+# {"name": "url", "description": {"zh": "请求URL", "en": "Request URL."}, "type": "string", "required": True},
+# {"name": "headers", "description": {"zh": "请求头", "en": "Request headers."}, "type": "object", "required": False}
+# ]},
+# {"name": "config_client", "description": {"zh": "配置HTTP客户端", "en": "Configure the HTTP client."}, "parameters": [
+# {"name": "connect_timeout", "description": {"zh": "连接超时时间(毫秒)", "en": "Connection timeout (milliseconds)."}, "type": "number", "required": False},
+# {"name": "read_timeout", "description": {"zh": "读取超时时间(毫秒)", "en": "Read timeout (milliseconds)."}, "type": "number", "required": False},
+# {"name": "write_timeout", "description": {"zh": "写入超时时间(毫秒)", "en": "Write timeout (milliseconds)."}, "type": "number", "required": False},
+# {"name": "follow_redirects", "description": {"zh": "是否跟随重定向", "en": "Whether to follow redirects."}, "type": "boolean", "required": False},
+# {"name": "retry_on_failure", "description": {"zh": "是否在连接失败时重试", "en": "Whether to retry on connection failure."}, "type": "boolean", "required": False}
+# ]},
+# {"name": "ping_test", "description": {"zh": "测试与指定URL的网络连接", "en": "Test network connectivity to a specified URL."}, "parameters": [
+# {"name": "url", "description": {"zh": "要测试的URL", "en": "URL to test."}, "type": "string", "required": True},
+# {"name": "count", "description": {"zh": "测试次数", "en": "Number of test attempts."}, "type": "number", "required": False}
+# ]},
+# {"name": "test_all", "description": {"zh": "运行所有网络测试", "en": "Run all network tests."}, "parameters": []}
+# ]
+# }
+
+import json
+import re
+import time
+import math
+from datetime import datetime
+from urllib.parse import quote, unquote
+
+Error = Exception
+
+# 默认客户端
+defaultClient = OkHttp.newClient()
+
+# 客户端配置
+clientConfig = {
+ "timeouts": {"connect": 10000, "read": 10000, "write": 10000},
+ "followRedirects": True,
+ "retryOnConnectionFailure": True,
+ "interceptors": [],
+}
+
+
+async def config_client(params):
+ global defaultClient
+ try:
+ if params.get("connect_timeout") is not None:
+ clientConfig["timeouts"]["connect"] = params.get("connect_timeout")
+ if params.get("read_timeout") is not None:
+ clientConfig["timeouts"]["read"] = params.get("read_timeout")
+ if params.get("write_timeout") is not None:
+ clientConfig["timeouts"]["write"] = params.get("write_timeout")
+ if params.get("follow_redirects") is not None:
+ clientConfig["followRedirects"] = params.get("follow_redirects")
+ if params.get("retry_on_failure") is not None:
+ clientConfig["retryOnConnectionFailure"] = params.get("retry_on_failure")
+
+ builder = (
+ OkHttp.newBuilder()
+ .connectTimeout(clientConfig["timeouts"]["connect"])
+ .readTimeout(clientConfig["timeouts"]["read"])
+ .writeTimeout(clientConfig["timeouts"]["write"])
+ .followRedirects(clientConfig["followRedirects"])
+ .retryOnConnectionFailure(clientConfig["retryOnConnectionFailure"])
+ )
+
+ if len(clientConfig["interceptors"]) > 0:
+ for interceptor in clientConfig["interceptors"]:
+ builder.addInterceptor(interceptor)
+
+ defaultClient = builder.build()
+
+ return {
+ "success": True,
+ "message": "HTTP客户端配置已更新",
+ "config": dict(clientConfig),
+ }
+ except Exception as error:
+ raise Error(f"配置HTTP客户端失败: {str(error)}")
+
+
+async def http_get(params):
+ try:
+ if not params.get("url"):
+ raise Error("URL不能为空")
+
+ console.log(f"发送GET请求: {params.get('url')}")
+ console.log(f"请求头: {json.dumps(params.get('headers') or {}, ensure_ascii=False)}")
+
+ request = defaultClient.newRequest().url(params.get("url")).method("GET")
+
+ if params.get("headers"):
+ request.headers(params.get("headers"))
+
+ response = await request.build().execute()
+
+ result = await formatResponse(response)
+
+ console.log(f"\n🟢 GET请求完成: {params.get('url')}")
+ console.log(f"🟢 状态码: {result['status_code']} {result['status_message']}")
+ if result.get("json"):
+ j = result["json"]
+ if isinstance(j, list):
+ console.log("🟢 返回数据类型: 数组")
+ elif isinstance(j, dict):
+ console.log("🟢 返回数据类型: 对象")
+ else:
+ console.log(f"🟢 返回数据类型: {type(j).__name__}")
+ console.log(f"🟢 响应大小: {result['content_length']} 字节\n")
+
+ return result
+ except Exception as error:
+ raise Error(f"GET请求失败: {str(error)}")
+
+
+async def http_post(params):
+ try:
+ if not params.get("url"):
+ raise Error("URL不能为空")
+
+ if params.get("body") is None:
+ raise Error("请求体不能为空")
+
+ console.log(f"发送POST请求: {params.get('url')}")
+
+ request = defaultClient.newRequest().url(params.get("url")).method("POST")
+
+ if params.get("headers"):
+ request.headers(params.get("headers"))
+
+ bodyType = params.get("body_type") or "json"
+ request.body(params.get("body"), bodyType)
+
+ response = await request.build().execute()
+
+ return await formatResponse(response)
+ except Exception as error:
+ raise Error(f"POST请求失败: {str(error)}")
+
+
+async def http_put(params):
+ try:
+ if not params.get("url"):
+ raise Error("URL不能为空")
+
+ if params.get("body") is None:
+ raise Error("请求体不能为空")
+
+ console.log(f"发送PUT请求: {params.get('url')}")
+
+ request = defaultClient.newRequest().url(params.get("url")).method("PUT")
+
+ if params.get("headers"):
+ request.headers(params.get("headers"))
+
+ bodyType = params.get("body_type") or "json"
+ request.body(params.get("body"), bodyType)
+
+ response = await request.build().execute()
+
+ return await formatResponse(response)
+ except Exception as error:
+ raise Error(f"PUT请求失败: {str(error)}")
+
+
+async def http_delete(params):
+ try:
+ if not params.get("url"):
+ raise Error("URL不能为空")
+
+ console.log(f"发送DELETE请求: {params.get('url')}")
+
+ request = defaultClient.newRequest().url(params.get("url")).method("DELETE")
+
+ if params.get("headers"):
+ request.headers(params.get("headers"))
+
+ response = await request.build().execute()
+
+ return await formatResponse(response)
+ except Exception as error:
+ raise Error(f"DELETE请求失败: {str(error)}")
+
+
+async def formatResponse(response):
+ try:
+ responseBody = ""
+ jsonData = None
+
+ responseBody = response.content
+ console.log("\n===== 响应内容开始 =====")
+ console.log(f"状态码: {response.statusCode} {getattr(response, 'statusMessage', '')}")
+ console.log(f"内容类型: {getattr(response, 'contentType', None) or '未知'}")
+
+ if responseBody and len(responseBody) < 1000:
+ console.log(f"响应体:\n{responseBody}")
+ elif responseBody:
+ console.log(f"响应体(截断):\n{responseBody[:500]}...")
+ console.log(f"[完整长度: {len(responseBody)} 字符]")
+ else:
+ console.log("响应体为空")
+
+ contentType = getattr(response, "contentType", None) or ""
+ if "application/json" in contentType:
+ try:
+ jsonData = response.json()
+ if jsonData is not None:
+ if isinstance(jsonData, list):
+ console.log(f"- 数组数据, 长度: {len(jsonData)}")
+ if len(jsonData) > 0:
+ sample = json.dumps(jsonData[0], ensure_ascii=False)
+ console.log(f"- 第一项样本: {sample[:100]}{'...' if len(sample) > 100 else ''}")
+ elif isinstance(jsonData, dict):
+ keys = list(jsonData.keys())
+ console.log(f"- 对象数据, 字段数: {len(keys)}")
+ console.log(f"- 字段列表: {', '.join(keys[:5])}{'...' if len(keys) > 5 else ''}")
+ if len(keys) > 0:
+ sampleKey = keys[0]
+ sampleValue = json.dumps(jsonData[sampleKey], ensure_ascii=False)
+ console.log(f'- 示例 "{sampleKey}": {sampleValue[:60]}{"..." if len(sampleValue) > 60 else ""}')
+
+ console.log("\nJSON完整数据:")
+ console.log(json.dumps(jsonData, indent=2, ensure_ascii=False))
+ else:
+ console.log(f"- 基本类型数据: {json.dumps(jsonData, ensure_ascii=False)[:100]}")
+ except Exception:
+ console.warn("响应内容不是有效的JSON格式")
+
+ console.log("===== 响应内容结束 =====\n")
+
+ return {
+ "success": response.isSuccessful(),
+ "status_code": response.statusCode,
+ "status_message": getattr(response, "statusMessage", ""),
+ "headers": getattr(response, "headers", None),
+ "content_type": getattr(response, "contentType", None),
+ "content_length": getattr(response, "size", 0),
+ "body": responseBody,
+ "json": jsonData,
+ "time_info": {"timestamp": datetime.now().isoformat()},
+ }
+ except Exception as error:
+ console.error(f"格式化响应出错: {str(error)}")
+ return {
+ "success": False,
+ "error": f"格式化响应出错: {str(error)}",
+ }
+
+
+async def ping_test(params):
+ try:
+ if not params.get("url"):
+ raise Error("URL不能为空")
+
+ count = params.get("count") or 3
+ results = []
+ totalTime = 0
+ successCount = 0
+ failCount = 0
+
+ console.log(f"开始Ping测试: {params.get('url')}, 次数: {count}")
+
+ for i in range(count):
+ startTime = time.time() * 1000
+ try:
+ request = defaultClient.newRequest().url(params.get("url")).method("HEAD").build()
+ response = await request.execute()
+ endTime = time.time() * 1000
+ elapsed = endTime - startTime
+
+ totalTime += elapsed
+ successCount += 1
+
+ results.append({
+ "attempt": i + 1,
+ "success": True,
+ "time_ms": elapsed,
+ "status": response.statusCode,
+ })
+
+ console.log(f"Ping #{i + 1}: {elapsed}ms, 状态: {response.statusCode}")
+
+ if i < count - 1:
+ await sleep(500)
+ except Exception as error:
+ endTime = time.time() * 1000
+ elapsed = endTime - startTime
+ failCount += 1
+
+ results.append({
+ "attempt": i + 1,
+ "success": False,
+ "time_ms": elapsed,
+ "error": str(error),
+ })
+
+ console.log(f"Ping #{i + 1}: 失败, 错误: {str(error)}")
+
+ if i < count - 1:
+ await sleep(500)
+
+ avgTime = totalTime / successCount if successCount > 0 else 0
+
+ return {
+ "url": params.get("url"),
+ "success": successCount > 0,
+ "summary": {
+ "total_count": count,
+ "success_count": successCount,
+ "fail_count": failCount,
+ "success_rate": f"{(successCount / count * 100):.1f}%",
+ "average_time": f"{avgTime:.2f}ms",
+ },
+ "detail_results": results,
+ }
+ except Exception as error:
+ raise Error(f"Ping测试失败: {str(error)}")
+
+
+async def sleep(ms):
+ sleepTime = float(ms)
+ if math.isnan(sleepTime):
+ raise Error("无效的等待时间")
+ await Tools.System.sleep(int(sleepTime))
+
+
+async def test_all():
+ try:
+ console.log("开始网络功能测试...")
+
+ results = {}
+
+ # 1. 测试客户端配置
+ console.log("测试客户端配置...")
+ try:
+ configResult = await config_client({
+ "connect_timeout": 8000,
+ "read_timeout": 8000,
+ "write_timeout": 8000,
+ "follow_redirects": True,
+ "retry_on_failure": True,
+ })
+ results["config"] = configResult
+ console.log("✓ 客户端配置成功")
+ except Exception as error:
+ results["config"] = {"error": f"客户端配置失败: {str(error)}"}
+ console.log("✗ 客户端配置失败")
+
+ # 2. 测试连接
+ console.log("测试网络连接...")
+ try:
+ pingResult = await ping_test({"url": "https://httpbin.org", "count": 2})
+ results["ping"] = pingResult
+ console.log("✓ Ping测试成功")
+ except Exception as error:
+ results["ping"] = {"error": f"Ping测试失败: {str(error)}"}
+ console.log("✗ Ping测试失败")
+
+ # 3. 测试GET请求
+ console.log("测试GET请求...")
+ try:
+ getResult = await http_get({
+ "url": "https://httpbin.org/get",
+ "headers": {"User-Agent": "OkHttp-Network-Tester/1.0"},
+ })
+ results["get"] = getResult
+ console.log("✓ GET请求成功")
+
+ console.log("\n====== GET请求结果摘要 ======")
+ console.log(f"状态: {getResult['status_code']} {getResult['status_message']}")
+
+ if getResult.get("json"):
+ console.log("GET响应数据预览:")
+ j = getResult["json"]
+ if isinstance(j, dict):
+ if j.get("headers"):
+ console.log("- 发送的请求头:")
+ for key in j["headers"]:
+ console.log(f" {key}: {j['headers'][key]}")
+ if j.get("url"):
+ console.log(f"- 请求URL: {j['url']}")
+ if j.get("origin"):
+ console.log(f"- 来源IP: {j['origin']}")
+ console.log("===============================\n")
+ except Exception as error:
+ results["get"] = {"error": f"GET请求失败: {str(error)}"}
+ console.log("✗ GET请求失败")
+
+ # 4. 测试POST请求
+ console.log("测试POST请求...")
+ try:
+ postResult = await http_post({
+ "url": "https://httpbin.org/post",
+ "body": {"name": "OkHttp网络测试", "timestamp": datetime.now().isoformat()},
+ "headers": {"Content-Type": "application/json", "User-Agent": "OkHttp-Network-Tester/1.0"},
+ })
+ results["post"] = postResult
+ console.log("✓ POST请求成功")
+ except Exception as error:
+ results["post"] = {"error": f"POST请求失败: {str(error)}"}
+ console.log("✗ POST请求失败")
+
+ # 5. 测试PUT请求
+ console.log("测试PUT请求...")
+ try:
+ putResult = await http_put({
+ "url": "https://httpbin.org/put",
+ "body": {"name": "OkHttp网络测试", "timestamp": datetime.now().isoformat(), "action": "update"},
+ "headers": {"Content-Type": "application/json", "User-Agent": "OkHttp-Network-Tester/1.0"},
+ })
+ results["put"] = putResult
+ console.log("✓ PUT请求成功")
+ except Exception as error:
+ results["put"] = {"error": f"PUT请求失败: {str(error)}"}
+ console.log("✗ PUT请求失败")
+
+ # 6. 测试DELETE请求
+ console.log("测试DELETE请求...")
+ try:
+ deleteResult = await http_delete({
+ "url": "https://httpbin.org/delete",
+ "headers": {"User-Agent": "OkHttp-Network-Tester/1.0"},
+ })
+ results["delete"] = deleteResult
+ console.log("✓ DELETE请求成功")
+ except Exception as error:
+ results["delete"] = {"error": f"DELETE请求失败: {str(error)}"}
+ console.log("✗ DELETE请求失败")
+
+ return {
+ "message": "网络功能测试完成",
+ "test_results": results,
+ "timestamp": datetime.now().isoformat(),
+ "summary": "测试了各种网络功能,包括配置、Ping测试和HTTP请求。请查看各功能的测试结果。",
+ }
+ except Exception as error:
+ return {
+ "success": False,
+ "message": f"测试过程中发生错误: {str(error)}",
+ }
+
+
+async def network_wrap(func, params, successMessage, failMessage, additionalInfo=""):
+ try:
+ console.log(f"开始执行函数: {func.__name__ or '匿名函数'}")
+ console.log("参数:", json.dumps(params, indent=2, ensure_ascii=False, default=str))
+
+ result = await func(params)
+
+ console.log(f"函数 {func.__name__ or '匿名函数'} 执行结果:", json.dumps(result, indent=2, ensure_ascii=False, default=str))
+
+ if result is None:
+ return
+
+ if isinstance(result, bool):
+ complete({
+ "success": result,
+ "message": successMessage if result else failMessage,
+ "additionalInfo": additionalInfo,
+ })
+ else:
+ complete({
+ "success": True,
+ "message": successMessage,
+ "additionalInfo": additionalInfo,
+ "data": result,
+ })
+ except Exception as error:
+ console.error(f"函数 {func.__name__ or '匿名函数'} 执行失败!")
+ console.error(f"错误信息: {str(error)}")
+ import traceback
+ stack = traceback.format_exc()
+ console.error(f"错误堆栈: {stack}")
+
+ complete({
+ "success": False,
+ "message": f"{failMessage}: {str(error)}",
+ "additionalInfo": additionalInfo,
+ "error_stack": stack,
+ })
+
+
+async def _config_client_wrapper(params):
+ await network_wrap(config_client, params, "配置HTTP客户端成功", "配置HTTP客户端失败")
+
+
+async def _http_get_wrapper(params):
+ await network_wrap(http_get, params, "GET请求成功", "GET请求失败")
+
+
+async def _http_post_wrapper(params):
+ await network_wrap(http_post, params, "POST请求成功", "POST请求失败")
+
+
+async def _http_put_wrapper(params):
+ await network_wrap(http_put, params, "PUT请求成功", "PUT请求失败")
+
+
+async def _http_delete_wrapper(params):
+ await network_wrap(http_delete, params, "DELETE请求成功", "DELETE请求失败")
+
+
+async def _ping_test_wrapper(params):
+ await network_wrap(ping_test, params, "网络连接测试成功", "网络连接测试失败")
+
+
+async def _test_all_wrapper(params):
+ await network_wrap(test_all, {}, "网络功能测试完成", "网络功能测试失败")
+
+
+exports.config_client = _config_client_wrapper
+exports.http_get = _http_get_wrapper
+exports.http_post = _http_post_wrapper
+exports.http_put = _http_put_wrapper
+exports.http_delete = _http_delete_wrapper
+exports.ping_test = _ping_test_wrapper
+exports.test_all = _test_all_wrapper
+exports.main = _test_all_wrapper
diff --git a/operit_py/openai_draw.py b/operit_py/openai_draw.py
new file mode 100644
index 0000000..7fb65fe
--- /dev/null
+++ b/operit_py/openai_draw.py
@@ -0,0 +1,303 @@
+# METADATA
+# {
+# "name": "openai_draw",
+#
+# "display_name": {
+# "zh": "OpenAI 绘图",
+# "en": "OpenAI Draw"
+# },
+# "description": {
+# "zh": "使用 OpenAI 格式的图像生成 API (/v1/images/generations) 根据提示词画图,将图片保存到本地 /sdcard/Download/Operit/plugins/draw/openai_draw/draws/ 目录,并返回 Markdown 图片提示。",
+# "en": "Generate images via an OpenAI-compatible image generation API (/v1/images/generations) from a prompt, save to /sdcard/Download/Operit/plugins/draw/openai_draw/draws/, and return a Markdown image reference."
+# },
+# "category": "Draw",
+# "env": [
+# {
+# "name": "OPENAI_API_KEY",
+# "description": {
+# "zh": "OpenAI API Key(必填)",
+# "en": "OpenAI API key (required)"
+# },
+# "required": true
+# },
+# {
+# "name": "OPENAI_API_BASE_URL",
+# "description": {
+# "zh": "OpenAI API Base URL(可选,不填则默认 https://api.openai.com )",
+# "en": "OpenAI API base URL (optional; defaults to https://api.openai.com)"
+# },
+# "required": false
+# },
+# {
+# "name": "OPENAI_IMAGE_MODEL",
+# "description": {
+# "zh": "默认绘图模型(可选;当 draw_image 未传 model 时使用)",
+# "en": "Default image model (optional; used when draw_image doesn't pass model)"
+# },
+# "required": false
+# }
+# ],
+# "tools": [
+# {
+# "name": "draw_image",
+# "description": {
+# "zh": "根据提示词调用 OpenAI 格式图像生成接口生成图片,保存到本地并返回 Markdown 图片提示。",
+# "en": "Generate an image via an OpenAI-compatible image generation endpoint using a prompt, save it locally, and return a Markdown image reference."
+# },
+# "parameters": [
+# { "name": "prompt", "description": { "zh": "绘图提示词(英文或中文皆可)", "en": "Prompt for image generation (Chinese or English)" }, "type": "string", "required": true },
+# { "name": "model", "description": { "zh": "模型名称(可选;不传则使用环境变量 OPENAI_IMAGE_MODEL,再不行使用默认值)", "en": "Model name (optional; falls back to env OPENAI_IMAGE_MODEL, then default)" }, "type": "string", "required": false },
+# { "name": "size", "description": { "zh": "图片尺寸,例如 '1024x1024',可选", "en": "Image size, e.g. '1024x1024' (optional)" }, "type": "string", "required": false },
+# { "name": "file_name", "description": { "zh": "自定义保存到本地的文件名(不含路径和扩展名)", "en": "Custom output file name (without path or extension)" }, "type": "string", "required": false },
+# { "name": "api_base_url", "description": { "zh": "OpenAI API Base URL(不传则取环境变量 OPENAI_API_BASE_URL 或默认 https://api.openai.com )", "en": "OpenAI API base URL (optional; falls back to env OPENAI_API_BASE_URL or https://api.openai.com)" }, "type": "string", "required": false }
+# ]
+# }
+# ]
+# }
+
+import json
+import re
+import time
+import random
+import traceback
+
+HTTP_TIMEOUT_MS = 600000
+client = OkHttp.newBuilder()
+client = client.connectTimeout(HTTP_TIMEOUT_MS)
+client = client.readTimeout(HTTP_TIMEOUT_MS)
+client = client.writeTimeout(HTTP_TIMEOUT_MS)
+client = client.build()
+DEFAULT_API_BASE_URL = "https://api.openai.com"
+DEFAULT_MODEL = "gpt-image-1"
+
+DRAW_ROOT_DIR = getPluginConfigDir("draw")
+STORAGE_DIR = f"{DRAW_ROOT_DIR}/openai_draw"
+DRAWS_DIR = f"{STORAGE_DIR}/draws"
+
+
+def getApiKey():
+ apiKey = getEnv("OPENAI_API_KEY")
+ if not apiKey:
+ raise Exception("OPENAI_API_KEY 未配置,请在环境变量中设置 OpenAI 的 API Key。")
+ return apiKey
+
+
+def joinUrl(baseUrl, path):
+ normalizedBase = baseUrl if baseUrl.endswith("/") else baseUrl + "/"
+ normalizedPath = path[1:] if path.startswith("/") else path
+ return normalizedBase + normalizedPath
+
+
+def getApiBaseUrl(customBaseUrl=None):
+ fromParam = (customBaseUrl or "").strip()
+ if fromParam:
+ return fromParam
+
+ fromEnv = (getEnv("OPENAI_API_BASE_URL") or "").strip()
+ if fromEnv:
+ return fromEnv
+
+ return DEFAULT_API_BASE_URL
+
+
+def getImageEndpoint(baseUrl):
+ trimmed = baseUrl.strip()
+ if not trimmed:
+ return joinUrl(DEFAULT_API_BASE_URL, "v1/images/generations")
+
+ if "/v1/images/generations" in trimmed:
+ return trimmed
+
+ # If user passes .../v1, we should append images/generations
+ if trimmed.endswith("/v1") or trimmed.endswith("/v1/"):
+ return joinUrl(trimmed, "images/generations")
+
+ # Otherwise append v1/images/generations
+ return joinUrl(trimmed, "v1/images/generations")
+
+
+def sanitizeFileName(name):
+ safe = re.sub(r'[\\/:*?"<>|]', '_', name).strip()
+ if not safe:
+ return f"openai_draw_{int(time.time() * 1000)}"
+ return safe[:80]
+
+
+def buildFileName(prompt, customName=None):
+ if customName and len(customName.strip()) > 0:
+ return sanitizeFileName(customName)
+ shortPrompt = (prompt[:40] + "...") if len(prompt) > 40 else prompt
+ base = sanitizeFileName(shortPrompt or "image")
+ timestamp = int(time.time() * 1000)
+ return f"{base}_{timestamp}"
+
+
+async def ensureDirectories():
+ dirs = [DRAW_ROOT_DIR, STORAGE_DIR, DRAWS_DIR]
+ for dir_ in dirs:
+ try:
+ result = await Tools.Files.mkdir(dir_)
+ if not result.get("successful"):
+ console.warn(f"创建目录失败(可能已存在): {dir_} -> {result.get('details')}")
+ except Exception as e:
+ console.warn(f"创建目录异常: {dir_} -> {getattr(e, 'message', e)}")
+
+
+def normalizeBase64(base64):
+ raw = str(base64 or "").strip()
+ if not raw:
+ return raw
+
+ # Some providers may return: data:image/png;base64,xxxx
+ prefixIndex = raw.find("base64,")
+ if raw.startswith("data:") and prefixIndex >= 0:
+ return raw[prefixIndex + len("base64,"):].strip()
+
+ return raw
+
+
+async def callOpenAIImageApi(params):
+ apiKey = getApiKey()
+ apiBaseUrl = getApiBaseUrl(params.get("api_base_url"))
+ endpoint = getImageEndpoint(apiBaseUrl)
+
+ modelFromParam = (params.get("model") or "").strip()
+ modelFromEnv = (getEnv("OPENAI_IMAGE_MODEL") or "").strip()
+ effectiveModel = modelFromParam or modelFromEnv or DEFAULT_MODEL
+
+ body = {
+ "model": effectiveModel,
+ "prompt": params["prompt"],
+ "response_format": "b64_json"
+ }
+
+ if params.get("size") and len(params["size"].strip()) > 0:
+ body["size"] = params["size"].strip()
+
+ headers = {
+ "accept": "application/json",
+ "content-type": "application/json",
+ "Authorization": f"Bearer {apiKey}"
+ }
+
+ request = client.newRequest()
+ request = request.url(endpoint)
+ request = request.method("POST")
+ request = request.headers(headers)
+ request = request.body(json.dumps(body), "json")
+
+ response = await request.build().execute()
+
+ if not response.isSuccessful():
+ raise Exception(f"OpenAI 图片 API 调用失败: {response.statusCode} - {response.content}")
+
+ try:
+ parsed = json.loads(response.content)
+ except Exception as e:
+ msg = str(getattr(e, "message", e))
+ raise Exception(f"解析 OpenAI 响应失败: {msg}")
+
+ data = parsed.get("data") if isinstance(parsed, dict) else None
+ item = data[0] if (data and len(data) > 0) else None
+ if not item:
+ raise Exception("OpenAI 响应中未找到 data[0],请检查接口返回格式。")
+
+ if item.get("b64_json") and len(str(item["b64_json"]).strip()) > 0:
+ return {
+ "b64_json": str(item["b64_json"]),
+ "revised_prompt": str(item["revised_prompt"]) if item.get("revised_prompt") else None,
+ "effective_model": effectiveModel
+ }
+
+ if item.get("url") and len(str(item["url"]).strip()) > 0:
+ # Some OpenAI-compatible APIs may return url only. We download it, then read as base64.
+ tmpName = f"openai_tmp_{int(time.time() * 1000)}"
+ tmpPath = f"{DRAWS_DIR}/{tmpName}.png"
+ downloadResult = await Tools.Files.download(str(item["url"]), tmpPath)
+ if not downloadResult.get("successful"):
+ raise Exception(f"下载图片失败: {downloadResult.get('details')}")
+
+ readBinary = await Tools.Files.readBinary(tmpPath)
+ contentBase64 = readBinary.get("contentBase64") if isinstance(readBinary, dict) else None
+ if not contentBase64 or len(str(contentBase64).strip()) == 0:
+ raise Exception("读取下载图片失败: contentBase64 为空")
+
+ try:
+ await Tools.Files.deleteFile(tmpPath)
+ except Exception:
+ # ignore
+ pass
+
+ return {
+ "b64_json": str(contentBase64),
+ "revised_prompt": str(item["revised_prompt"]) if item.get("revised_prompt") else None,
+ "effective_model": effectiveModel
+ }
+
+ raise Exception("OpenAI 响应中未找到 b64_json 或 url,请检查模型/参数以及接口兼容性。")
+
+
+async def draw_image(params):
+ if not params or not params.get("prompt") or len(params.get("prompt", "").strip()) == 0:
+ raise Exception("参数 prompt 不能为空。")
+
+ prompt = params["prompt"].strip()
+
+ await ensureDirectories()
+
+ apiResult = await callOpenAIImageApi({
+ "prompt": prompt,
+ "model": params.get("model"),
+ "size": params.get("size"),
+ "api_base_url": params.get("api_base_url")
+ })
+
+ baseName = buildFileName(prompt, params.get("file_name"))
+ filePath = f"{DRAWS_DIR}/{baseName}.png"
+
+ writeResult = await Tools.Files.writeBinary(filePath, normalizeBase64(apiResult["b64_json"]))
+ if not writeResult.get("successful"):
+ raise Exception(f"保存图片失败: {writeResult.get('details')}")
+
+ fileUri = f"file://{filePath}"
+ markdown = f""
+
+ hintLines = []
+ hintLines.append(f"图片已生成并保存在本地 {DRAWS_DIR}。")
+ hintLines.append(f"本地路径: {filePath}")
+ hintLines.append("")
+ hintLines.append("在后续回答中,请直接输出下面这一行 Markdown 来展示这张图片:")
+ hintLines.append("")
+ hintLines.append(markdown)
+
+ return {
+ "file_path": filePath,
+ "file_uri": fileUri,
+ "markdown": markdown,
+ "prompt": prompt,
+ "revised_prompt": apiResult.get("revised_prompt") or None,
+ "model": apiResult["effective_model"],
+ "hint": "\n".join(hintLines)
+ }
+
+
+async def draw_image_wrapper(params):
+ try:
+ result = await draw_image(params)
+ complete({
+ "success": True,
+ "message": f"图片生成成功,已保存到 {DRAWS_DIR},并返回 Markdown 图片提示。",
+ "data": result
+ })
+ except Exception as error:
+ console.error("draw_image 执行失败:", error)
+ msg = str(getattr(error, "message", error))
+ stack = traceback.format_exc() if isinstance(error, Exception) else None
+ complete({
+ "success": False,
+ "message": f"图片生成失败: {msg}",
+ "error_stack": stack
+ })
+
+
+exports.draw_image = draw_image_wrapper
diff --git a/operit_py/operit_editor.py b/operit_py/operit_editor.py
new file mode 100644
index 0000000..69e4b48
--- /dev/null
+++ b/operit_py/operit_editor.py
@@ -0,0 +1,1069 @@
+# METADATA
+# {
+# "name": "operit_editor",
+# "display_name": { "zh": "Operit平台编辑器", "en": "Operit Platform Editor" },
+# "description": { "zh": "Operit 平台配置直改工具包:提供一组可直接读取与修改 Operit 平台设置的工具,覆盖 MCP、Skill、Sandbox Package、功能模型绑定、模型参数、上下文总结与 TTS/STT 语音服务配置。", "en": "Direct Operit platform configuration toolkit: a collection of tools for reading and directly modifying Operit platform settings, covering MCP, Skill, Sandbox Package, function-model bindings, model parameters, context-summary settings, and TTS/STT speech-service configuration." },
+# "enabledByDefault": true,
+# "category": "Chat",
+# "tools": [
+# { "name": "operit_editor", "description": "配置排查手册", "parameters": [], "advice": true },
+# { "name": "how_make_skill", "description": "返回如何制作 skill 的双语说明", "parameters": [] },
+# { "name": "list_sandbox_packages", "description": "获取沙盒包列表(内置+外部)及当前启用状态", "parameters": [] },
+# { "name": "set_sandbox_package_enabled", "description": "设置沙盒包开关状态", "parameters": [ { "name": "package_name", "type": "string", "required": true }, { "name": "enabled", "type": "boolean", "required": true } ] },
+# { "name": "debug_install_js_package", "description": "将 Android 侧的普通 .js 沙盒包直接烧录到外部 packages 目录", "parameters": [ { "name": "source_path", "type": "string", "required": true }, { "name": "enable_after_install", "type": "boolean", "required": false }, { "name": "activate_after_install", "type": "boolean", "required": false } ] },
+# { "name": "debug_install_toolpkg", "description": "根据 Android 侧的 ToolPkg 目录、manifest 或现成 .toolpkg,直接打包/烧录到外部 packages 目录", "parameters": [ { "name": "source_path", "type": "string", "required": true }, { "name": "reset_subpackage_states", "type": "boolean", "required": false }, { "name": "activate_subpackages", "type": "string", "required": false }, { "name": "wait_ms", "type": "integer", "required": false } ] },
+# { "name": "debug_run_sandbox_script", "description": "直接运行一段 sandbox script", "parameters": [ { "name": "source_path", "type": "string", "required": false }, { "name": "source_code", "type": "string", "required": false }, { "name": "params_json", "type": "string", "required": false }, { "name": "env_file_path", "type": "string", "required": false }, { "name": "script_label", "type": "string", "required": false }, { "name": "wait_ms", "type": "integer", "required": false } ] },
+# { "name": "read_environment_variable", "description": "读取指定环境变量当前值", "parameters": [ { "name": "key", "type": "string", "required": true } ] },
+# { "name": "write_environment_variable", "description": "写入指定环境变量", "parameters": [ { "name": "key", "type": "string", "required": true }, { "name": "value", "type": "string", "required": false } ] },
+# { "name": "restart_mcp_with_logs", "description": "触发一次 MCP 重启流程,返回每个插件的启动日志与状态摘要", "parameters": [ { "name": "timeout_ms", "type": "integer", "required": false } ] },
+# { "name": "get_speech_services_config", "description": "获取当前 TTS/STT 语音服务配置快照", "parameters": [] },
+# { "name": "set_speech_services_config", "description": "按字段更新 TTS/STT 语音服务配置", "parameters": [] },
+# { "name": "test_tts_playback", "description": "按当前 TTS 配置播放一次测试文本", "parameters": [ { "name": "text", "type": "string", "required": true }, { "name": "interrupt", "type": "boolean", "required": false }, { "name": "speech_rate", "type": "number", "required": false }, { "name": "pitch", "type": "number", "required": false } ] },
+# { "name": "list_model_configs", "description": "列出全部模型配置及功能模型绑定关系", "parameters": [] },
+# { "name": "create_model_config", "description": "新增模型配置", "parameters": [] },
+# { "name": "update_model_config", "description": "按 config_id 修改模型配置", "parameters": [ { "name": "config_id", "type": "string", "required": true } ] },
+# { "name": "delete_model_config", "description": "删除模型配置", "parameters": [ { "name": "config_id", "type": "string", "required": true } ] },
+# { "name": "list_function_model_configs", "description": "列出功能 -> 配置绑定关系", "parameters": [] },
+# { "name": "get_function_model_config", "description": "查看某个功能当前绑定的单个配置详情", "parameters": [ { "name": "function_type", "type": "string", "required": true } ] },
+# { "name": "get_context_summary_config", "description": "查看上下文总结参数", "parameters": [ { "name": "function_type", "type": "string", "required": false } ] },
+# { "name": "set_context_summary_config", "description": "设置上下文总结参数", "parameters": [ { "name": "function_type", "type": "string", "required": false } ] },
+# { "name": "set_function_model_config", "description": "为功能指定配置与模型索引", "parameters": [ { "name": "function_type", "type": "string", "required": true }, { "name": "config_id", "type": "string", "required": true }, { "name": "model_index", "type": "number", "required": false } ] },
+# { "name": "test_model_config_connection", "description": "按设置页同等逻辑测试配置连通与多模态能力", "parameters": [ { "name": "config_id", "type": "string", "required": true }, { "name": "model_index", "type": "number", "required": false } ] },
+# { "name": "ping_mcp", "description": "快速探测指定包是否可被加载", "parameters": [ { "name": "package_name", "type": "string", "required": true } ] }
+# ]
+# }
+
+import json
+import re
+import time
+import math
+from datetime import datetime
+
+Error = Exception
+
+# Android 专属常量(Android-only paths)
+SANDBOX_EXTERNAL_PACKAGES_DIR = "/sdcard/Android/data/com.ai.assistance.operit/files/packages" # Android-only
+TOOLPKG_DEBUG_INSTALL_ACTION = "com.ai.assistance.operit.DEBUG_INSTALL_TOOLPKG"
+TOOLPKG_DEBUG_INSTALL_COMPONENT = "com.ai.assistance.operit/.core.tools.packTool.ToolPkgDebugInstallReceiver"
+SANDBOX_SCRIPT_EXECUTION_ACTION = "com.ai.assistance.operit.EXECUTE_JS"
+SANDBOX_SCRIPT_EXECUTION_COMPONENT = "com.ai.assistance.operit/com.ai.assistance.operit.core.tools.javascript.ScriptExecutionReceiver"
+SANDBOX_SCRIPT_EXECUTION_MODE_SCRIPT = "script"
+SANDBOX_SCRIPT_EXECUTION_MODE_CODE = "code"
+SANDBOX_JS_TEMP_DIR = "/sdcard/Android/data/com.ai.assistance.operit/js_temp" # Android-only
+DEFAULT_SANDBOX_REFRESH_TIMEOUT_MS = 1500
+DEFAULT_TOOLPKG_INSTALL_WAIT_MS = 1500
+DEFAULT_SANDBOX_SCRIPT_WAIT_MS = 15000
+
+JS_METADATA_BLOCK_PATTERN = re.compile(r'/\*\s*METADATA([\s\S]*?)\*/', re.MULTILINE)
+JS_PACKAGE_NAME_PATTERN = re.compile(r'^\s*["\']?name["\']?\s*:\s*["\']([^"\']+)["\']', re.MULTILINE)
+TOOLPKG_ID_PATTERN = re.compile(r'^\s*["\']?toolpkg_id["\']?\s*:\s*["\']([^"\']+)["\']', re.MULTILINE)
+TOOLPKG_MAIN_PATTERN = re.compile(r'^\s*["\']?main["\']?\s*:\s*["\']([^"\']+)["\']', re.MULTILINE)
+TOOLPKG_SUBPACKAGE_ID_PATTERN = re.compile(r'^\s*["\']?id["\']?\s*:\s*["\']([^"\']+)["\']', re.MULTILINE)
+TOOLPKG_SKIP_DIR_NAMES = {".git", "__pycache__"}
+TOOLPKG_SKIP_FILE_NAMES = {".DS_Store", "Thumbs.db"}
+
+
+def get_error_message(error):
+ return str(error) if isinstance(error, Exception) else "Unknown error"
+
+
+def normalize_android_path(raw=None):
+ normalized = str(raw if raw is not None else "").strip().replace("\\", "/")
+ if not normalized:
+ return ""
+ if re.match(r'^[a-zA-Z]+://', normalized):
+ return normalized
+ if normalized.startswith("/"):
+ return normalized
+ if normalized.startswith("sdcard/"):
+ return f"/{normalized}"
+ if normalized.startswith("Android/") or normalized.startswith("Download/"):
+ return f"/sdcard/{normalized}"
+ return normalized
+
+
+def normalize_package_key(raw=None):
+ return str(raw if raw is not None else "").strip().lower()
+
+
+def normalize_directory_path(path):
+ normalized = normalize_android_path(path)
+ normalized = re.sub(r'/+', '/', normalized)
+ if normalized == "/":
+ return normalized
+ return normalized.rstrip("/")
+
+
+def same_android_path(left, right):
+ return normalize_directory_path(left) == normalize_directory_path(right)
+
+
+def path_dirname(path):
+ normalized = normalize_directory_path(path)
+ index = normalized.rfind("/")
+ if index < 0:
+ return ""
+ if index == 0:
+ return "/"
+ return normalized[:index]
+
+
+def path_basename(path):
+ normalized = normalize_directory_path(path)
+ index = normalized.rfind("/")
+ return normalized[index + 1:] if index >= 0 else normalized
+
+
+def path_join(*parts):
+ filtered = [str(p if p is not None else "").strip().replace("\\", "/") for p in parts]
+ filtered = [p for p in filtered if p]
+ if len(filtered) == 0:
+ return ""
+ leadingSlash = filtered[0].startswith("/")
+ joined = "/".join([re.sub(r'^/+|/+$', '', p) for p in filtered if p])
+ return f"/{joined}" if leadingSlash else joined
+
+
+def safe_debug_file_stem(raw, fallback):
+ normalized = re.sub(r'[^A-Za-z0-9._-]+', '_', str(raw if raw is not None else "").strip())
+ normalized = re.sub(r'^[_.]+|[_.]+$', '', normalized)
+ return normalized or fallback
+
+
+def parse_boolean_like(value, defaultValue):
+ if value is None or value == "":
+ return defaultValue
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, (int, float)):
+ return value != 0
+ normalized = str(value).strip().lower()
+ if not normalized:
+ return defaultValue
+ if normalized in ("true", "1", "yes", "on"):
+ return True
+ if normalized in ("false", "0", "no", "off"):
+ return False
+ return defaultValue
+
+
+def parse_integer_like(value, defaultValue):
+ if value is None or value == "":
+ return defaultValue
+ try:
+ parsed = float(value)
+ if math.isfinite(parsed) and parsed >= 0:
+ return int(parsed)
+ return defaultValue
+ except (ValueError, TypeError):
+ return defaultValue
+
+
+async def android_path_exists(path):
+ result = await Tools.Files.exists(path, "android")
+ return result.exists
+
+
+async def get_android_file_type(path):
+ result = await Tools.Files.info(path, "android")
+ return result.fileType.strip().lower()
+
+
+async def ensure_android_directory(path):
+ await Tools.Files.mkdir(path, True, "android")
+
+
+async def delete_android_path_if_exists(path):
+ if not path:
+ return
+ if not await android_path_exists(path):
+ return
+ await Tools.Files.deleteFile(path, True, "android")
+
+
+async def cleanup_android_paths(paths):
+ uniquePaths = list(set(normalize_android_path(p) for p in paths if normalize_android_path(p)))
+ uniquePaths.sort(key=lambda x: len(x), reverse=True)
+ for path in uniquePaths:
+ try:
+ await delete_android_path_if_exists(path)
+ except Exception:
+ pass
+
+
+async def read_android_text_file(path):
+ result = await Tools.Files.read({"path": path, "environment": "android"})
+ return result.content
+
+
+def parse_json_record(raw):
+ if not raw:
+ return None
+ try:
+ parsed = json.loads(raw)
+ if not parsed or not isinstance(parsed, dict) or isinstance(parsed, list):
+ return None
+ return parsed
+ except Exception:
+ return None
+
+
+def find_sandbox_package_entry(payload, packageName):
+ if not payload:
+ return None
+ targetKey = normalize_package_key(packageName)
+ packages = payload.get("packages", []) if payload else []
+ for entry in packages:
+ if normalize_package_key(entry.get("packageName", "") if entry else "") == targetKey:
+ return entry
+ return None
+
+
+def collect_related_package_load_errors(payload, packageName, *relatedPaths):
+ normalizedPackageName = normalize_package_key(packageName)
+ normalizedPaths = [str(p or "").strip() for p in relatedPaths if str(p or "").strip()]
+ packageLoadErrors = payload.get("packageLoadErrors") if payload else None
+ if not packageLoadErrors:
+ return {}
+ result = {}
+ for key, value in packageLoadErrors.items():
+ normalizedKey = normalize_package_key(key)
+ message = str(value or "")
+ if (normalizedKey == normalizedPackageName or
+ message.lower().find(normalizedPackageName) >= 0 or
+ any(p in message for p in normalizedPaths)):
+ result[key] = value
+ return result
+
+
+async def refresh_sandbox_packages_until(packageName, timeoutMs):
+ deadline = time.time() * 1000 + max(0, timeoutMs)
+ lastPayload = None
+ lastEntry = None
+ while True:
+ lastPayload = await Tools.SoftwareSettings.listSandboxPackages()
+ lastEntry = find_sandbox_package_entry(lastPayload, packageName)
+ if lastEntry:
+ return {"payload": lastPayload, "packageEntry": lastEntry}
+ if time.time() * 1000 >= deadline:
+ return {"payload": lastPayload, "packageEntry": lastEntry}
+ await Tools.System.sleep(min(300, max(50, deadline - time.time() * 1000)))
+
+
+async def wait_for_android_file(path, timeoutMs):
+ deadline = time.time() * 1000 + max(0, timeoutMs)
+ while True:
+ if await android_path_exists(path):
+ return True
+ if time.time() * 1000 >= deadline:
+ return False
+ await Tools.System.sleep(min(300, max(50, deadline - time.time() * 1000)))
+
+
+def parse_json_text(raw):
+ text = str(raw if raw is not None else "").strip()
+ if not text:
+ return None
+ try:
+ return json.loads(text)
+ except Exception:
+ return None
+
+
+def extract_js_metadata_block(sourceText, sourcePath):
+ match = JS_METADATA_BLOCK_PATTERN.search(sourceText)
+ if not match:
+ raise Error(f"Missing METADATA block: {sourcePath}")
+ return match.group(1).strip()
+
+
+def parse_js_package_source(sourceText, sourcePath):
+ metadataBlock = extract_js_metadata_block(sourceText, sourcePath)
+ match = JS_PACKAGE_NAME_PATTERN.search(metadataBlock)
+ packageName = match.group(1).strip() if match else ""
+ if not packageName:
+ raise Error(f"Missing package metadata name: {sourcePath}")
+ return {"packageName": packageName, "metadataBlock": metadataBlock}
+
+
+async def delete_duplicate_external_js_package_files(packageName, keepPath):
+ removedPaths = []
+ listing = await Tools.Files.list(SANDBOX_EXTERNAL_PACKAGES_DIR, "android")
+ entries = listing.entries if listing and hasattr(listing, "entries") else []
+ for entry in entries:
+ entryName = str(entry.get("name", "") if isinstance(entry, dict) else getattr(entry, "name", "")).strip()
+ if not entryName:
+ continue
+ isDir = entry.get("isDirectory", False) if isinstance(entry, dict) else getattr(entry, "isDirectory", False)
+ if isDir or not entryName.lower().endswith(".js"):
+ continue
+ candidatePath = path_join(SANDBOX_EXTERNAL_PACKAGES_DIR, entryName)
+ if same_android_path(candidatePath, keepPath):
+ continue
+ try:
+ candidateText = await read_android_text_file(candidatePath)
+ candidateInfo = parse_js_package_source(candidateText, candidatePath)
+ if normalize_package_key(candidateInfo["packageName"]) != normalize_package_key(packageName):
+ continue
+ await Tools.Files.deleteFile(candidatePath, False, "android")
+ removedPaths.append(candidatePath)
+ except Exception:
+ pass
+ return removedPaths
+
+
+def parse_toolpkg_manifest_text(text, manifestPath):
+ packageId = ""
+ mainEntry = ""
+ subpackageIds = []
+
+ try:
+ parsed = json.loads(text)
+ if parsed and isinstance(parsed, dict) and not isinstance(parsed, list):
+ packageId = str(parsed.get("toolpkg_id", "")).strip()
+ mainEntry = str(parsed.get("main", "")).strip()
+ if isinstance(parsed.get("subpackages"), list):
+ subpackageIds = [str(sp.get("id", "")).strip() for sp in parsed["subpackages"] if str(sp.get("id", "")).strip()]
+ except Exception:
+ pass
+
+ if not packageId:
+ match = TOOLPKG_ID_PATTERN.search(text)
+ packageId = match.group(1).strip() if match else ""
+ if not mainEntry:
+ match = TOOLPKG_MAIN_PATTERN.search(text)
+ mainEntry = match.group(1).strip() if match else ""
+ if len(subpackageIds) == 0:
+ matches = []
+ for m in TOOLPKG_SUBPACKAGE_ID_PATTERN.finditer(text):
+ subpackageId = m.group(1).strip()
+ if subpackageId:
+ matches.append(subpackageId)
+ subpackageIds = matches
+
+ if not packageId:
+ raise Error(f"manifest.toolpkg_id is required: {manifestPath}")
+ if not mainEntry:
+ raise Error(f"manifest.main is required: {manifestPath}")
+
+ return {
+ "packageId": packageId,
+ "mainEntry": mainEntry.replace("\\", "/").lstrip("/"),
+ "subpackageIds": list(set(subpackageIds))
+ }
+
+
+async def find_toolpkg_manifest_in_folder(folderPath):
+ manifestJson = path_join(folderPath, "manifest.json")
+ if await android_path_exists(manifestJson):
+ return manifestJson
+ manifestHjson = path_join(folderPath, "manifest.hjson")
+ if await android_path_exists(manifestHjson):
+ return manifestHjson
+ raise Error(f"Missing manifest.json or manifest.hjson in folder: {folderPath}")
+
+
+async def resolve_toolpkg_source(rawSourcePath):
+ sourcePath = normalize_android_path(rawSourcePath)
+ if not sourcePath:
+ raise Error("Missing required parameter: source_path")
+ if not await android_path_exists(sourcePath):
+ raise Error(f"Source path does not exist: {sourcePath}")
+
+ sourceType = await get_android_file_type(sourcePath)
+ sourceKind = "folder"
+ folderPath = sourcePath
+ archivePath = None
+ temporaryPaths = []
+
+ lowerBaseName = path_basename(sourcePath).lower()
+ if sourceType == "directory":
+ folderPath = sourcePath
+ elif sourceType == "file" and lowerBaseName in ("manifest.json", "manifest.hjson"):
+ folderPath = path_dirname(sourcePath)
+ elif sourceType == "file" and lowerBaseName.endswith(".toolpkg"):
+ sourceKind = "archive"
+ archivePath = sourcePath
+ _safe_stem = safe_debug_file_stem(re.sub(r'\.toolpkg$', '', lowerBaseName, flags=re.IGNORECASE), 'toolpkg')
+ tempExtractDir = path_join(
+ OPERIT_CLEAN_ON_EXIT_DIR,
+ f"operit_editor_toolpkg_extract_{_safe_stem}_{int(time.time() * 1000)}"
+ )
+ await ensure_android_directory(tempExtractDir)
+ await Tools.Files.unzip(sourcePath, tempExtractDir, "android")
+ folderPath = tempExtractDir
+ temporaryPaths.append(tempExtractDir)
+ else:
+ raise Error("ToolPkg source must be a folder, manifest.json/manifest.hjson, or an existing .toolpkg file")
+
+ manifestPath = await find_toolpkg_manifest_in_folder(folderPath)
+ manifestText = await read_android_text_file(manifestPath)
+ manifest = parse_toolpkg_manifest_text(manifestText, manifestPath)
+ mainPath = path_join(folderPath, manifest["mainEntry"])
+ if not await android_path_exists(mainPath):
+ raise Error(f"manifest.main file does not exist: {manifestPath} -> {manifest['mainEntry']}")
+
+ return {
+ "sourceKind": sourceKind,
+ "sourcePath": sourcePath,
+ "folderPath": folderPath,
+ "manifestPath": manifestPath,
+ "packageId": manifest["packageId"],
+ "mainEntry": manifest["mainEntry"],
+ "subpackageIds": manifest["subpackageIds"],
+ "archivePath": archivePath,
+ "temporaryPaths": temporaryPaths
+ }
+
+
+async def build_toolpkg_archive_from_folder(source):
+ tempBuildDir = path_join(
+ OPERIT_CLEAN_ON_EXIT_DIR,
+ f"operit_editor_toolpkg_build_{safe_debug_file_stem(source['packageId'], 'toolpkg')}_{int(time.time() * 1000)}"
+ )
+ await ensure_android_directory(tempBuildDir)
+ archivePath = path_join(tempBuildDir, f"{safe_debug_file_stem(source['packageId'], 'toolpkg')}.toolpkg")
+ await Tools.Files.zip(source["folderPath"], archivePath, "android", False)
+ if not await android_path_exists(archivePath):
+ raise Error(f"Failed to create ToolPkg archive: {archivePath}")
+ return {"archivePath": archivePath, "temporaryPaths": [tempBuildDir]}
+
+
+def parse_requested_package_ids(raw=None):
+ input_str = str(raw if raw is not None else "").strip()
+ if not input_str:
+ return []
+ return list(set(item.strip() for item in re.split(r'[\r\n,]+', input_str) if item.strip()))
+
+
+async def operit_editor(params):
+ try:
+ query = (params or {}).get("query", "")
+ complete({
+ "success": True,
+ "message": "配置排查手册已加载(MCP/Skill/Sandbox Package/沙盒包调试烧录/功能模型与模型配置/TTS-STT语音服务),将按配置链路执行排查。",
+ "data": {"query": query}
+ })
+ except Exception as error:
+ complete({"success": False, "message": get_error_message(error)})
+
+
+async def how_make_skill():
+ try:
+ locale = (getLang() or "").lower()
+ lang = "zh" if locale.startswith("zh") else ("en" if locale.startswith("en") else "both")
+
+ zh = """如何制作 skill(简版)
+1. 先创建目录:/sdcard/Download/Operit/skills//
+2. 必备文件:SKILL.md
+3. 在 SKILL.md 顶部用 Markdown 元数据(frontmatter)写 name、description
+4. 元数据后再写正文:适用场景、执行步骤、约束边界、期望输出
+5. 可选内容:scripts/、templates/、examples/、assets/
+6. 实践建议:优先下载现成 skill,直接解压过来,并确保目录下有 SKILL.md。"""
+
+ en = """How to make a skill (quick guide)
+1. Create a directory: /sdcard/Download/Operit/skills//
+2. Required file: SKILL.md
+3. At the top of SKILL.md, use Markdown metadata (frontmatter) for name and description
+4. After metadata, write the main sections: use cases, workflow steps, constraints, expected outputs
+5. Optional content: scripts/, templates/, examples/, assets/
+6. Practical tip: download an existing skill, extract it directly, and ensure the directory contains SKILL.md"""
+
+ message = zh if lang == "zh" else (en if lang == "en" else f"{zh}\n\n---\n\n{en}")
+ complete({"success": True, "message": message, "data": {"lang": lang, "zh": zh, "en": en}})
+ except Exception as error:
+ complete({"success": False, "message": get_error_message(error)})
+
+
+async def list_sandbox_packages():
+ try:
+ result = await Tools.SoftwareSettings.listSandboxPackages()
+ complete({
+ "success": True,
+ "message": f"Sandbox package list fetched: {result.totalCount} package(s).",
+ "data": result
+ })
+ except Exception as error:
+ complete({"success": False, "message": get_error_message(error)})
+
+
+async def set_sandbox_package_enabled(params=None):
+ try:
+ packageName = (params or {}).get("package_name", "")
+ enabled = (params or {}).get("enabled", False)
+ result = await Tools.SoftwareSettings.setSandboxPackageEnabled(packageName, enabled)
+ complete({
+ "success": True,
+ "message": result.message or "Sandbox package switch updated.",
+ "data": result
+ })
+ except Exception as error:
+ complete({"success": False, "message": get_error_message(error)})
+
+
+async def debug_install_js_package(params=None):
+ logs = []
+ def logStep(message):
+ logs.append(message)
+ def finish(payload):
+ data = dict(payload.get("data", {}))
+ data["logs"] = logs
+ complete({**payload, "data": data})
+
+ try:
+ sourcePath = normalize_android_path((params or {}).get("source_path"))
+ logStep(f"Resolved source_path -> {sourcePath or ''}")
+ if not sourcePath:
+ finish({"success": False, "message": "Missing required parameter: source_path"})
+ return
+
+ sourceType = await get_android_file_type(sourcePath)
+ logStep(f"Source type detected -> {sourceType}")
+ if sourceType != "file":
+ finish({"success": False, "message": f"JS source must be a file: {sourcePath}"})
+ return
+ if not sourcePath.lower().endswith(".js"):
+ finish({"success": False, "message": f"JS debug install only supports .js files: {sourcePath}"})
+ return
+
+ sourceText = await read_android_text_file(sourcePath)
+ packageInfo = parse_js_package_source(sourceText, sourcePath)
+ logStep(f"Parsed package info -> packageName={packageInfo['packageName']}")
+ enableAfterInstall = parse_boolean_like((params or {}).get("enable_after_install"), True)
+ activateAfterInstall = parse_boolean_like((params or {}).get("activate_after_install"), True)
+ shouldEnable = enableAfterInstall or activateAfterInstall
+ logStep(f"Install options -> enableAfterInstall={enableAfterInstall}, activateAfterInstall={activateAfterInstall}, shouldEnable={shouldEnable}")
+
+ await ensure_android_directory(SANDBOX_EXTERNAL_PACKAGES_DIR)
+ targetPath = path_join(SANDBOX_EXTERNAL_PACKAGES_DIR, f"{safe_debug_file_stem(packageInfo['packageName'], 'debug_js_package')}.js")
+ logStep(f"Target install path -> {targetPath}")
+ copied = not same_android_path(sourcePath, targetPath)
+ if copied:
+ logStep("Source and target differ; replacing target file before copy.")
+ await delete_android_path_if_exists(targetPath)
+ await Tools.Files.copy(sourcePath, targetPath, False, "android", "android")
+ logStep("Package file copied to external sandbox directory.")
+ else:
+ logStep("Source path already matches target path; skipping file copy.")
+ if not await android_path_exists(targetPath):
+ finish({"success": False, "message": f"Installed JS package file is missing after copy: {targetPath}"})
+ return
+ logStep("Verified installed JS package file exists.")
+
+ removedDuplicateFiles = await delete_duplicate_external_js_package_files(packageInfo["packageName"], targetPath)
+ logStep(f"Duplicate cleanup completed -> removed {len(removedDuplicateFiles)} file(s).")
+ refresh = await refresh_sandbox_packages_until(packageInfo["packageName"], DEFAULT_SANDBOX_REFRESH_TIMEOUT_MS)
+ logStep(f"Sandbox refresh completed -> found={bool(refresh['packageEntry'])}, builtIn={refresh['packageEntry'].get('isBuiltIn', False) if refresh['packageEntry'] else False}")
+ if not refresh["packageEntry"]:
+ finish({"success": False, "message": f"Sandbox package did not appear after refresh: {packageInfo['packageName']}",
+ "data": {"package_name": packageInfo["packageName"], "source_path": sourcePath, "target_path": targetPath, "refresh_result": refresh["payload"]}})
+ return
+ if refresh["packageEntry"].get("isBuiltIn"):
+ finish({"success": False, "message": f"External JS package '{packageInfo['packageName']}' did not take precedence over a built-in package with the same name.",
+ "data": {"package": refresh["packageEntry"], "source_path": sourcePath, "target_path": targetPath}})
+ return
+
+ enableResult = None
+ if shouldEnable:
+ logStep(f"Enabling sandbox package -> {packageInfo['packageName']}")
+ enableResult = await Tools.SoftwareSettings.setSandboxPackageEnabled(packageInfo["packageName"], True)
+ logStep(f"Enable result -> {enableResult.message or ''}")
+ else:
+ logStep("Enable step skipped by configuration.")
+
+ activateResult = None
+ if activateAfterInstall:
+ logStep(f"Activating package via use_package -> {packageInfo['packageName']}")
+ activateResult = await Tools.System.usePackage(packageInfo["packageName"])
+ logStep(f"Activation result -> {activateResult or ''}")
+ else:
+ logStep("Activation step skipped by configuration.")
+
+ finish({"success": True, "message": f"Debug JS package installed: {packageInfo['packageName']}",
+ "data": {"package_name": packageInfo["packageName"], "source_path": sourcePath, "target_path": targetPath,
+ "copied": copied, "removed_duplicate_files": removedDuplicateFiles, "package": refresh["packageEntry"],
+ "enable_after_install": shouldEnable, "activate_after_install": activateAfterInstall,
+ "enable_result": enableResult, "activate_result": activateResult, "refresh_result": refresh["payload"]}})
+ except Exception as error:
+ logStep(f"Execution failed -> {get_error_message(error)}")
+ finish({"success": False, "message": get_error_message(error)})
+
+
+async def debug_install_toolpkg(params=None):
+ cleanupPaths = []
+ logs = []
+ def logStep(message):
+ logs.append(message)
+ def finish(payload):
+ data = dict(payload.get("data", {}))
+ data["logs"] = logs
+ complete({**payload, "data": data})
+ finalPayload = None
+
+ try:
+ resolvedSource = await resolve_toolpkg_source((params or {}).get("source_path", ""))
+ logStep(f"Resolved ToolPkg source -> kind={resolvedSource['sourceKind']}, packageId={resolvedSource['packageId']}, sourcePath={resolvedSource['sourcePath']}")
+ cleanupPaths.extend(resolvedSource["temporaryPaths"])
+ if len(resolvedSource["temporaryPaths"]) > 0:
+ logStep(f"Registered temporary paths -> {', '.join(resolvedSource['temporaryPaths'])}")
+
+ archivePath = resolvedSource.get("archivePath") or ""
+ if resolvedSource["sourceKind"] == "folder":
+ logStep("Source is a folder; building temporary .toolpkg archive.")
+ builtArchive = await build_toolpkg_archive_from_folder(resolvedSource)
+ archivePath = builtArchive["archivePath"]
+ cleanupPaths.extend(builtArchive["temporaryPaths"])
+ logStep(f"Built archive -> {archivePath}")
+
+ await ensure_android_directory(SANDBOX_EXTERNAL_PACKAGES_DIR)
+ targetPath = path_join(SANDBOX_EXTERNAL_PACKAGES_DIR, f"{safe_debug_file_stem(resolvedSource['packageId'], 'toolpkg')}.toolpkg")
+ logStep(f"Target install path -> {targetPath}")
+ if not same_android_path(archivePath, targetPath):
+ logStep("Archive path differs from target; replacing target archive before copy.")
+ await delete_android_path_if_exists(targetPath)
+ await Tools.Files.copy(archivePath, targetPath, False, "android", "android")
+ logStep("ToolPkg archive copied to external sandbox directory.")
+ else:
+ logStep("Archive path already matches target path; skipping archive copy.")
+ if not await android_path_exists(targetPath):
+ finalPayload = {"success": False, "message": f"Installed ToolPkg archive is missing after copy: {targetPath}"}
+ return
+ logStep("Verified installed ToolPkg archive exists.")
+
+ resetSubpackageStates = parse_boolean_like((params or {}).get("reset_subpackage_states"), True)
+ waitMs = parse_integer_like((params or {}).get("wait_ms"), DEFAULT_TOOLPKG_INSTALL_WAIT_MS)
+ logStep(f"Install options -> resetSubpackageStates={resetSubpackageStates}, waitMs={waitMs}")
+ broadcastResult = await Tools.System.sendBroadcast({
+ "action": TOOLPKG_DEBUG_INSTALL_ACTION,
+ "component": TOOLPKG_DEBUG_INSTALL_COMPONENT,
+ "extras": {"package_name": resolvedSource["packageId"], "file_path": targetPath, "reset_subpackage_states": resetSubpackageStates}
+ })
+ logStep(f"Debug install broadcast dispatched -> {broadcastResult.result or ''}")
+
+ refresh = await refresh_sandbox_packages_until(resolvedSource["packageId"], waitMs)
+ relatedLoadErrors = collect_related_package_load_errors(refresh["payload"], resolvedSource["packageId"], resolvedSource["sourcePath"], archivePath, targetPath)
+ logStep(f"Sandbox refresh completed -> found={bool(refresh['packageEntry'])}, builtIn={refresh['packageEntry'].get('isBuiltIn', False) if refresh['packageEntry'] else False}")
+ if len(relatedLoadErrors) > 0:
+ logStep(f"Related load errors -> {json.dumps(relatedLoadErrors)}")
+ if not refresh["packageEntry"]:
+ finalPayload = {"success": False, "message": f"ToolPkg container did not appear after debug install: {resolvedSource['packageId']}",
+ "data": {"package_name": resolvedSource["packageId"], "source_path": resolvedSource["sourcePath"], "archive_path": targetPath,
+ "broadcast_result": broadcastResult, "refresh_result": refresh["payload"], "related_load_errors": relatedLoadErrors}}
+ return
+ if refresh["packageEntry"].get("isBuiltIn"):
+ finalPayload = {"success": False, "message": f"Debug ToolPkg '{resolvedSource['packageId']}' is shadowed by a built-in package with the same name.",
+ "data": {"package": refresh["packageEntry"], "broadcast_result": broadcastResult, "refresh_result": refresh["payload"], "related_load_errors": relatedLoadErrors}}
+ return
+
+ requestedSubpackages = parse_requested_package_ids((params or {}).get("activate_subpackages"))
+ knownSubpackageKeys = set(normalize_package_key(sid) for sid in resolvedSource["subpackageIds"])
+ unknownRequestedSubpackages = ([] if len(knownSubpackageKeys) == 0
+ else [sid for sid in requestedSubpackages if normalize_package_key(sid) not in knownSubpackageKeys])
+ activationTargets = [sid for sid in requestedSubpackages if sid not in unknownRequestedSubpackages]
+ logStep(f"Subpackage activation plan -> requested={', '.join(requestedSubpackages) or ''}, targets={', '.join(activationTargets) or ''}, unknown={', '.join(unknownRequestedSubpackages) or ''}")
+
+ subpackageResults = []
+ for subpackageId in activationTargets:
+ logStep(f"Enabling subpackage -> {subpackageId}")
+ enableResult = await Tools.SoftwareSettings.setSandboxPackageEnabled(subpackageId, True)
+ logStep(f"Subpackage enable result [{subpackageId}] -> {enableResult.message or ''}")
+ activateResult = await Tools.System.usePackage(subpackageId)
+ logStep(f"Subpackage activate result [{subpackageId}] -> {activateResult or ''}")
+ subpackageResults.append({"subpackage_id": subpackageId, "enable_result": enableResult, "activate_result": activateResult})
+
+ finalPayload = {"success": True, "message": f"Debug ToolPkg installed: {resolvedSource['packageId']}",
+ "data": {"package_name": resolvedSource["packageId"], "source_kind": resolvedSource["sourceKind"], "source_path": resolvedSource["sourcePath"],
+ "manifest_path": resolvedSource["manifestPath"], "main_entry": resolvedSource["mainEntry"], "archive_path": targetPath,
+ "subpackage_ids": resolvedSource["subpackageIds"], "reset_subpackage_states": resetSubpackageStates,
+ "requested_activate_subpackages": requestedSubpackages, "unknown_requested_subpackages": unknownRequestedSubpackages,
+ "subpackage_results": subpackageResults, "package": refresh["packageEntry"], "broadcast_result": broadcastResult,
+ "refresh_result": refresh["payload"], "related_load_errors": relatedLoadErrors}}
+ except Exception as error:
+ logStep(f"Execution failed -> {get_error_message(error)}")
+ finalPayload = {"success": False, "message": get_error_message(error)}
+ finally:
+ if len(cleanupPaths) > 0:
+ logStep(f"Cleaning temporary paths -> {', '.join(cleanupPaths)}")
+ await cleanup_android_paths(cleanupPaths)
+ if len(cleanupPaths) > 0:
+ logStep("Temporary path cleanup completed.")
+ if finalPayload:
+ finish(finalPayload)
+
+
+async def debug_run_sandbox_script(params=None):
+ logs = []
+ def logStep(message):
+ logs.append(message)
+ def finish(payload):
+ data = dict(payload.get("data", {}))
+ data["logs"] = logs
+ complete({**payload, "data": data})
+ finalPayload = None
+
+ try:
+ sourcePath = normalize_android_path((params or {}).get("source_path"))
+ sourceCode = params.get("source_code", "") if params and isinstance(params.get("source_code"), str) else ""
+ hasInlineCode = len(sourceCode.strip()) > 0
+ waitMs = parse_integer_like((params or {}).get("wait_ms"), DEFAULT_SANDBOX_SCRIPT_WAIT_MS)
+ paramsJson = str((params or {}).get("params_json", "{}")).strip() or "{}"
+ parsedParams = parse_json_text(paramsJson)
+ envFilePath = normalize_android_path((params or {}).get("env_file_path"))
+ scriptLabel = safe_debug_file_stem(str((params or {}).get("script_label", "")).strip(), "sandbox_script")
+
+ logStep(f"Resolved input -> sourcePath={sourcePath or ''}, hasInlineCode={hasInlineCode}, waitMs={waitMs}")
+
+ if not sourcePath and not hasInlineCode:
+ finalPayload = {"success": False, "message": "Either source_path or source_code is required."}
+ return
+
+ if sourcePath:
+ sourceType = await get_android_file_type(sourcePath)
+ logStep(f"Source type detected -> {sourceType}")
+ if sourceType != "file":
+ finalPayload = {"success": False, "message": f"Sandbox script source must be a file: {sourcePath}"}
+ return
+
+ if not parsedParams or not isinstance(parsedParams, dict) or isinstance(parsedParams, list):
+ finalPayload = {"success": False, "message": f"params_json must be a JSON object: {paramsJson}"}
+ return
+ logStep("params_json parsed successfully.")
+
+ if envFilePath:
+ envType = await get_android_file_type(envFilePath)
+ logStep(f"Env file type detected -> {envType}")
+ if envType != "file":
+ finalPayload = {"success": False, "message": f"env_file_path must be a file: {envFilePath}"}
+ return
+
+ executionMode = SANDBOX_SCRIPT_EXECUTION_MODE_CODE if hasInlineCode else SANDBOX_SCRIPT_EXECUTION_MODE_SCRIPT
+ scriptIdentityPath = sourcePath or path_join(SANDBOX_JS_TEMP_DIR, f"{scriptLabel}_{int(time.time() * 1000)}.inline.js")
+ logStep(f"Execution mode -> {executionMode}")
+ logStep(f"Execution target -> {scriptIdentityPath}")
+
+ executionResult = await Tools.SoftwareSettings.executeSandboxScriptDirect({
+ "source_path": sourcePath or None,
+ "source_code": sourceCode if hasInlineCode else None,
+ "params_json": paramsJson,
+ "env_file_path": envFilePath or None,
+ "script_label": scriptLabel,
+ "wait_ms": waitMs
+ })
+ logStep(f"Direct execution tool completed -> success={bool(executionResult.success)}, durationMs={executionResult.durationMs if hasattr(executionResult, 'durationMs') else ''}")
+
+ finalPayload = {
+ "success": executionResult.success,
+ "message": "Sandbox script executed successfully." if executionResult.success else str(getattr(executionResult, "error", "Sandbox script execution failed.")),
+ "data": {"execution_mode": executionMode, "source_path": sourcePath, "has_inline_code": hasInlineCode,
+ "env_file_path": envFilePath or None, "params_json": paramsJson, "execution_result": executionResult}
+ }
+ except Exception as error:
+ logStep(f"Execution failed -> {get_error_message(error)}")
+ finalPayload = {"success": False, "message": get_error_message(error)}
+ finally:
+ finish(finalPayload or {"success": False, "message": "Sandbox script execution did not produce a final result."})
+
+
+async def read_environment_variable(params=None):
+ try:
+ key = ((params or {}).get("key", "") or "").strip()
+ if not key:
+ complete({"success": False, "message": "Missing required parameter: key"})
+ return
+ result = await Tools.SoftwareSettings.readEnvironmentVariable(key)
+ complete({
+ "success": True,
+ "message": f"Environment variable read: {key}" if result.exists else f"Environment variable not set: {key}",
+ "data": result
+ })
+ except Exception as error:
+ complete({"success": False, "message": get_error_message(error)})
+
+
+async def write_environment_variable(params=None):
+ try:
+ key = ((params or {}).get("key", "") or "").strip()
+ if not key:
+ complete({"success": False, "message": "Missing required parameter: key"})
+ return
+ value = (params or {}).get("value", "")
+ result = await Tools.SoftwareSettings.writeEnvironmentVariable(key, str(value))
+ complete({
+ "success": True,
+ "message": f"Environment variable cleared: {key}" if result.cleared else f"Environment variable written: {key}",
+ "data": result
+ })
+ except Exception as error:
+ complete({"success": False, "message": get_error_message(error)})
+
+
+async def restart_mcp_with_logs(params=None):
+ try:
+ timeoutMs = (params or {}).get("timeout_ms") if params else None
+ result = await Tools.SoftwareSettings.restartMcpWithLogs(timeoutMs)
+ complete({
+ "success": True,
+ "message": (f"MCP restart timed out after {result.elapsedMs}ms." if result.timedOut
+ else f"MCP restart completed: {result.successCount} success, {result.failedCount} failed."),
+ "data": result
+ })
+ except Exception as error:
+ complete({"success": False, "message": get_error_message(error)})
+
+
+async def get_speech_services_config():
+ try:
+ result = await Tools.SoftwareSettings.getSpeechServicesConfig()
+ complete({"success": True, "message": "Speech services config fetched.", "data": {"parsed": result}})
+ except Exception as error:
+ complete({"success": False, "message": get_error_message(error)})
+
+
+async def set_speech_services_config(params=None):
+ try:
+ updates = dict(params or {})
+ result = await Tools.SoftwareSettings.setSpeechServicesConfig(updates)
+ complete({"success": True, "message": "Speech services config updated.", "data": {"updates": updates, "parsed": result}})
+ except Exception as error:
+ complete({"success": False, "message": get_error_message(error)})
+
+
+async def test_tts_playback(params=None):
+ try:
+ text = ((params or {}).get("text", "") or "").strip()
+ if not text:
+ complete({"success": False, "message": "Missing required parameter: text"})
+ return
+ options = dict(params or {})
+ options.pop("text", None)
+ result = await Tools.SoftwareSettings.testTtsPlayback(text, options)
+ success = result.playbackTriggered
+ detailMessage = (result.errorMessage or "").strip() or "TTS playback test failed."
+ complete({"success": success, "message": "TTS playback test triggered." if success else detailMessage, "data": result})
+ except Exception as error:
+ complete({"success": False, "message": get_error_message(error)})
+
+
+async def list_model_configs():
+ try:
+ result = await Tools.SoftwareSettings.listModelConfigs()
+ complete({"success": True, "message": "Model configs listed.", "data": result})
+ except Exception as error:
+ complete({"success": False, "message": get_error_message(error)})
+
+
+async def create_model_config(params=None):
+ try:
+ options = dict(params or {})
+ result = await Tools.SoftwareSettings.createModelConfig(options)
+ complete({"success": True, "message": "Model config created.", "data": result})
+ except Exception as error:
+ complete({"success": False, "message": get_error_message(error)})
+
+
+async def update_model_config(params=None):
+ try:
+ configId = ((params or {}).get("config_id", "") or "").strip()
+ if not configId:
+ complete({"success": False, "message": "Missing required parameter: config_id"})
+ return
+ updates = dict(params or {})
+ updates.pop("config_id", None)
+ result = await Tools.SoftwareSettings.updateModelConfig(configId, updates)
+ complete({"success": True, "message": "Model config updated.", "data": result})
+ except Exception as error:
+ complete({"success": False, "message": get_error_message(error)})
+
+
+async def delete_model_config(params=None):
+ try:
+ configId = ((params or {}).get("config_id", "") or "").strip()
+ if not configId:
+ complete({"success": False, "message": "Missing required parameter: config_id"})
+ return
+ result = await Tools.SoftwareSettings.deleteModelConfig(configId)
+ complete({"success": True, "message": "Model config deleted.", "data": result})
+ except Exception as error:
+ complete({"success": False, "message": get_error_message(error)})
+
+
+async def list_function_model_configs():
+ try:
+ result = await Tools.SoftwareSettings.listFunctionModelConfigs()
+ complete({"success": True, "message": "Function model bindings listed.", "data": result})
+ except Exception as error:
+ complete({"success": False, "message": get_error_message(error)})
+
+
+def pick_context_summary_fields(config):
+ if not config:
+ config = {}
+ return {
+ "context_length": config.get("contextLength"),
+ "max_context_length": config.get("maxContextLength"),
+ "enable_max_context_mode": config.get("enableMaxContextMode"),
+ "summary_token_threshold": config.get("summaryTokenThreshold"),
+ "enable_summary": config.get("enableSummary"),
+ "enable_summary_by_message_count": config.get("enableSummaryByMessageCount"),
+ "summary_message_count_threshold": config.get("summaryMessageCountThreshold")
+ }
+
+
+async def get_function_model_config(params=None):
+ try:
+ functionType = ((params or {}).get("function_type", "") or "").strip()
+ if not functionType:
+ complete({"success": False, "message": "Missing required parameter: function_type"})
+ return
+ result = await Tools.SoftwareSettings.getFunctionModelConfig(functionType)
+ config = (result.config if result and hasattr(result, "config") else {}) or {}
+ contextSummaryKeys = {"contextLength", "maxContextLength", "enableMaxContextMode", "summaryTokenThreshold",
+ "enableSummary", "enableSummaryByMessageCount", "summaryMessageCountThreshold"}
+ configWithoutContextSummary = {k: v for k, v in config.items() if k not in contextSummaryKeys}
+ filteredResult = {**result.__dict__} if hasattr(result, "__dict__") else dict(result)
+ filteredResult["config"] = configWithoutContextSummary
+ complete({"success": True, "message": "Function model config fetched.", "data": filteredResult})
+ except Exception as error:
+ complete({"success": False, "message": get_error_message(error)})
+
+
+async def get_context_summary_config(params=None):
+ try:
+ functionType = ((params or {}).get("function_type", "CHAT") or "CHAT").strip().upper()
+ if not functionType:
+ complete({"success": False, "message": "Missing required parameter: function_type"})
+ return
+ result = await Tools.SoftwareSettings.getFunctionModelConfig(functionType)
+ config = (result.config if result and hasattr(result, "config") else {}) or {}
+ if not config:
+ complete({"success": False, "message": f"No bound config found for function_type: {functionType}", "data": result})
+ return
+ complete({
+ "success": True,
+ "message": f"Context summary config fetched for {functionType}.",
+ "data": {
+ "function_type": functionType,
+ "config_id": getattr(result, "configId", "") or config.get("id", ""),
+ "config_name": getattr(result, "configName", "") or config.get("name", ""),
+ "model_index": getattr(result, "modelIndex", 0),
+ "actual_model_index": getattr(result, "actualModelIndex", 0),
+ "selected_model": getattr(result, "selectedModel", ""),
+ "context_summary": pick_context_summary_fields(config),
+ "raw": result
+ }
+ })
+ except Exception as error:
+ complete({"success": False, "message": get_error_message(error)})
+
+
+async def set_context_summary_config(params=None):
+ try:
+ functionType = ((params or {}).get("function_type", "CHAT") or "CHAT").strip().upper()
+ if not functionType:
+ complete({"success": False, "message": "Missing required parameter: function_type"})
+ return
+ binding = await Tools.SoftwareSettings.getFunctionModelConfig(functionType)
+ bindingConfig = (binding.config if binding and hasattr(binding, "config") else {}) or {}
+ configId = (getattr(binding, "configId", "") or bindingConfig.get("id", "") or "").strip()
+ if not configId:
+ complete({"success": False, "message": f"No bound config found for function_type: {functionType}", "data": binding})
+ return
+
+ defaultUpdates = {
+ "context_length": 48, "max_context_length": 128, "enable_max_context_mode": True,
+ "summary_token_threshold": 0.7, "enable_summary": True,
+ "enable_summary_by_message_count": True, "summary_message_count_threshold": 16
+ }
+ updates = dict(defaultUpdates)
+ p = params or {}
+ for key in ["context_length", "max_context_length", "enable_max_context_mode", "summary_token_threshold",
+ "enable_summary", "enable_summary_by_message_count", "summary_message_count_threshold"]:
+ if key in p and p[key] is not None:
+ updates[key] = p[key]
+
+ updateResult = await Tools.SoftwareSettings.updateModelConfig(configId, updates)
+ updateResultConfig = (updateResult.config if updateResult and hasattr(updateResult, "config") else {}) or {}
+ complete({
+ "success": True,
+ "message": f"Context summary config updated for {functionType}.",
+ "data": {
+ "function_type": functionType, "config_id": configId, "applied_updates": updates,
+ "before": pick_context_summary_fields(bindingConfig),
+ "after": pick_context_summary_fields(updateResultConfig),
+ "update_result": updateResult
+ }
+ })
+ except Exception as error:
+ complete({"success": False, "message": get_error_message(error)})
+
+
+async def set_function_model_config(params=None):
+ try:
+ functionType = ((params or {}).get("function_type", "") or "").strip()
+ configId = ((params or {}).get("config_id", "") or "").strip()
+ if not functionType:
+ complete({"success": False, "message": "Missing required parameter: function_type"})
+ return
+ if not configId:
+ complete({"success": False, "message": "Missing required parameter: config_id"})
+ return
+ result = await Tools.SoftwareSettings.setFunctionModelConfig(functionType, configId, (params or {}).get("model_index"))
+ complete({"success": True, "message": "Function model binding updated.", "data": result})
+ except Exception as error:
+ complete({"success": False, "message": get_error_message(error)})
+
+
+async def test_model_config_connection(params=None):
+ try:
+ configId = ((params or {}).get("config_id", "") or "").strip()
+ if not configId:
+ complete({"success": False, "message": "Missing required parameter: config_id"})
+ return
+ result = await Tools.SoftwareSettings.testModelConfigConnection(configId, (params or {}).get("model_index"))
+ success = bool(result and result.success)
+ complete({
+ "success": success,
+ "message": "Model config connection tests passed." if success else "Model config connection tests have failures.",
+ "data": result
+ })
+ except Exception as error:
+ complete({"success": False, "message": get_error_message(error)})
+
+
+async def ping_mcp(params=None):
+ try:
+ packageName = ((params or {}).get("package_name", "") or "").strip()
+ if not packageName:
+ complete({"success": False, "message": "Missing required parameter: package_name"})
+ return
+ result = await Tools.System.usePackage(packageName)
+ complete({"success": True, "message": f"Package probe finished: {packageName}", "data": result})
+ except Exception as error:
+ complete({"success": False, "message": get_error_message(error)})
+
+
+# 导出
+exports.operit_editor = operit_editor
+exports.how_make_skill = how_make_skill
+exports.list_sandbox_packages = list_sandbox_packages
+exports.set_sandbox_package_enabled = set_sandbox_package_enabled
+exports.debug_install_js_package = debug_install_js_package
+exports.debug_install_toolpkg = debug_install_toolpkg
+exports.debug_run_sandbox_script = debug_run_sandbox_script
+exports.read_environment_variable = read_environment_variable
+exports.write_environment_variable = write_environment_variable
+exports.restart_mcp_with_logs = restart_mcp_with_logs
+exports.get_speech_services_config = get_speech_services_config
+exports.set_speech_services_config = set_speech_services_config
+exports.test_tts_playback = test_tts_playback
+exports.list_model_configs = list_model_configs
+exports.create_model_config = create_model_config
+exports.update_model_config = update_model_config
+exports.delete_model_config = delete_model_config
+exports.list_function_model_configs = list_function_model_configs
+exports.get_function_model_config = get_function_model_config
+exports.get_context_summary_config = get_context_summary_config
+exports.set_context_summary_config = set_context_summary_config
+exports.set_function_model_config = set_function_model_config
+exports.test_model_config_connection = test_model_config_connection
+exports.ping_mcp = ping_mcp
diff --git a/operit_py/pdf_vision_parser.py b/operit_py/pdf_vision_parser.py
new file mode 100644
index 0000000..12cfc8d
--- /dev/null
+++ b/operit_py/pdf_vision_parser.py
@@ -0,0 +1,585 @@
+# METADATA
+# {
+# "name": "pdf_vision_parser",
+# "display_name": {"zh": "PDF 逐页识图解析", "en": "PDF Vision Parser"},
+# "description": {"zh": "将 PDF 逐页渲染为图片,并调用用户当前配置的识图模型进行解析,最后按页拼接结果。", "en": "Render a PDF page by page into images, send each image to the user's configured vision model, and merge the results."},
+# "enabledByDefault": False,
+# "category": "File",
+# "tools": [
+# {"name": "parse_pdf_with_vision", "description": {"zh": "将 PDF 逐页拆成图片,逐页调用 IMAGE_RECOGNITION 功能模型识图,并返回分页索引与输出文件路径。", "en": "Split a PDF into page images, analyze each page with the IMAGE_RECOGNITION function model, and return per-page indexes plus output file paths."}, "parameters": [
+# {"name": "pdf_path", "description": {"zh": "PDF 文件路径。", "en": "Path to the PDF file."}, "type": "string", "required": True},
+# {"name": "page_prompt", "description": {"zh": "每页识图提示词;不传则使用默认的尽量转文字提示词。", "en": "Vision prompt for each page; defaults to a text-extraction-oriented prompt."}, "type": "string", "required": False},
+# {"name": "start_page", "description": {"zh": "起始页码,1-based,默认 1。", "en": "Start page number, 1-based, default 1."}, "type": "number", "required": False},
+# {"name": "end_page", "description": {"zh": "结束页码,1-based,默认文档最后一页。", "en": "End page number, 1-based, default is the last page."}, "type": "number", "required": False}
+# ]}
+# ]
+# }
+
+import json
+import re
+import time
+import math
+import os
+from urllib.parse import quote, unquote
+
+Error = Exception
+
+DEFAULT_PAGE_PROMPT = (
+ "尽量完整提取本页可见文字与结构,保留标题、列表、表格顺序,不做总结,不补充页外信息;公式和图表转成可读文本描述"
+)
+TOOL_NAME = "parse_pdf_with_vision"
+TOOL_FUNCTION_TYPE = "IMAGE_RECOGNITION"
+RENDER_SCALE = 2
+PAGE_CONCURRENCY = 4
+IMAGE_FORMAT = "png"
+MERGED_OUTPUT_FILE_NAME = "parsed_output.txt"
+
+# Android 专属 Java 类降级为 stub(跨平台不支持)
+# 原 TypeScript 使用 Java.type("java.io.File") 等 Android API,此处降级
+
+
+class AndroidUnsupportedStub:
+ """Android 专属 Java 类的降级 stub,调用任何方法均返回不支持提示。"""
+
+ def __init__(self, *args, **kwargs):
+ pass
+
+ def __getattr__(self, name):
+ def _unsupported(*args, **kwargs):
+ raise Error("此功能仅在 Android 上可用")
+
+ return _unsupported
+
+
+# 降级:原 Java.type(...) 调用
+File = AndroidUnsupportedStub
+FileOutputStream = AndroidUnsupportedStub
+OutputStreamWriter = AndroidUnsupportedStub
+BufferedWriter = AndroidUnsupportedStub
+ParcelFileDescriptor = AndroidUnsupportedStub
+PdfRenderer = AndroidUnsupportedStub
+PdfRendererPage = AndroidUnsupportedStub
+Bitmap = AndroidUnsupportedStub
+BitmapConfig = AndroidUnsupportedStub
+BitmapCompressFormat = AndroidUnsupportedStub
+Color = AndroidUnsupportedStub
+StandardCharsets = AndroidUnsupportedStub
+EnhancedAIService = AndroidUnsupportedStub
+
+
+def isBlank(value):
+ return len(str(value if value is not None else "").strip()) == 0
+
+
+def toTrimmedString(value):
+ return str(value if value is not None else "").strip()
+
+
+def parsePositiveInteger(value, fieldName, options=None):
+ options = options or {}
+ if value is None:
+ if options.get("allowUndefined"):
+ return None
+ raise Error(f"{fieldName} 不能为空。")
+
+ if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
+ raise Error(f"{fieldName} 必须是大于 0 的整数。")
+ return value
+
+
+def buildMergedText(pages):
+ return "\n\n".join(
+ [f"===== Page {page['page_number']} =====\n{page['analysis']}" for page in pages]
+ )
+
+
+def buildVisionModelInfo(binding):
+ return {
+ "function_type": TOOL_FUNCTION_TYPE,
+ "config_id": str(getattr(binding, "configId", "") or ""),
+ "config_name": str(getattr(binding, "configName", "") or ""),
+ "selected_model": str(getattr(binding, "selectedModel", "") or ""),
+ }
+
+
+def buildData(pdfPath, imageDir, outputPath, totalPageCount, pages, visionModel, failedPage=None):
+ data = {
+ "pdf_path": pdfPath,
+ "image_dir": imageDir,
+ "output_path": outputPath,
+ "total_page_count": totalPageCount,
+ "processed_page_count": len(pages),
+ "pages": [
+ {
+ "page_number": page["page_number"],
+ "image_path": page["image_path"],
+ "image_link": page["image_link"],
+ "output_path": page["output_path"],
+ }
+ for page in pages
+ ],
+ "vision_model": visionModel,
+ }
+
+ if failedPage:
+ data["failed_page"] = failedPage
+
+ return data
+
+
+def buildPrecheckFailure(message):
+ return {
+ "success": False,
+ "message": message,
+ "data": {
+ "pdf_path": "",
+ "image_dir": "",
+ "output_path": "",
+ "total_page_count": 0,
+ "processed_page_count": 0,
+ "pages": [],
+ "vision_model": {
+ "function_type": TOOL_FUNCTION_TYPE,
+ "config_id": "",
+ "config_name": "",
+ "selected_model": "",
+ },
+ "failed_page": {
+ "page_number": 0,
+ "stage": "precheck",
+ "error": message,
+ },
+ },
+ }
+
+
+def safeClose(resource, label):
+ if not resource:
+ return
+ try:
+ resource.close()
+ except Exception as error:
+ console.warn(f"[{TOOL_NAME}] 关闭 {label} 失败: {str(error)}")
+
+
+def safeRecycle(bitmap):
+ if not bitmap:
+ return
+ try:
+ bitmap.recycle()
+ except Exception as error:
+ console.warn(f"[{TOOL_NAME}] 回收 bitmap 失败: {str(error)}")
+
+
+def ensureDirectoryExists(dir_):
+ if dir_.exists():
+ if not dir_.isDirectory():
+ raise Error(f"目标路径不是目录: {str(dir_.getAbsolutePath())}")
+ return
+ created = dir_.mkdirs()
+ if not created:
+ raise Error(f"无法创建目录: {str(dir_.getAbsolutePath())}")
+
+
+def resolveAppContext():
+ if hasattr(Java, "getApplicationContext") and callable(getattr(Java, "getApplicationContext", None)):
+ return Java.getApplicationContext()
+ if hasattr(Java, "getContext") and callable(getattr(Java, "getContext", None)):
+ return Java.getContext()
+ raise Error("无法获取应用 Context。")
+
+
+def createOutputDirectory():
+ parserRootDir = File(OPERIT_CLEAN_ON_EXIT_DIR, "pdf_vision_parser")
+ ensureDirectoryExists(parserRootDir)
+
+ timestamp = time.strftime("%Y-%m-%dT%H-%M-%S", time.gmtime())
+ runDir = File(parserRootDir, timestamp)
+ ensureDirectoryExists(runDir)
+ return runDir
+
+
+def writeTextFile(targetFile, content):
+ outputStream = None
+ writer = None
+ bufferedWriter = None
+ try:
+ outputStream = FileOutputStream(targetFile)
+ writer = OutputStreamWriter(outputStream, StandardCharsets.UTF_8)
+ bufferedWriter = BufferedWriter(writer)
+ bufferedWriter.write(str(content if content is not None else ""))
+ bufferedWriter.flush()
+ return str(targetFile.getAbsolutePath())
+ finally:
+ safeClose(bufferedWriter, "bufferedWriter")
+ safeClose(writer, "writer")
+ safeClose(outputStream, "outputStream")
+
+
+def writePageOutputFile(outputDir, pageNumber, content):
+ textFile = File(outputDir, f"page-{padPageNumber(pageNumber)}.txt")
+ return writeTextFile(textFile, content)
+
+
+def writeMergedOutputFile(outputDir, pages):
+ if not outputDir:
+ return ""
+ mergedFile = File(outputDir, MERGED_OUTPUT_FILE_NAME)
+ return writeTextFile(mergedFile, buildMergedText(pages))
+
+
+def padPageNumber(pageNumber):
+ return str(pageNumber).rjust(4, "0")
+
+
+def assertVisionBinding(binding):
+ if not binding or isBlank(getattr(binding, "configId", None)):
+ raise Error("IMAGE_RECOGNITION 当前没有可用绑定。")
+ config = getattr(binding, "config", None)
+ if not config or getattr(config, "enableDirectImageProcessing", None) is not True:
+ raise Error("IMAGE_RECOGNITION 当前绑定的模型未启用识图能力。")
+
+
+def validatePdfFile(pdfPath):
+ if isBlank(pdfPath):
+ raise Error("pdf_path 不能为空。")
+
+ pdfFile = File(pdfPath)
+ if not pdfFile.exists() or not pdfFile.isFile():
+ raise Error(f"PDF 文件不存在: {pdfPath}")
+
+ if not str(pdfFile.getName()).lower().endswith(".pdf"):
+ raise Error(f"文件不是 PDF: {pdfPath}")
+
+ return pdfFile
+
+
+def stripThinkingContent(content):
+ result = str(content if content is not None else "")
+ result = re.sub(r"[\s\S]*?(|\Z)", "", result, flags=re.IGNORECASE)
+ result = re.sub(r"[\s\S]*?(|\Z)", "", result, flags=re.IGNORECASE)
+ return result.strip()
+
+
+def sortPageResults(pages):
+ return sorted([p for p in pages], key=lambda p: p["page_number"])
+
+
+def normalizeErrorMessage(error):
+ return str(error.message if isinstance(error, Exception) else error)
+
+
+async def analyzePageImage(service, imagePath, prompt):
+ rawAnalysis = str(await service.callSuspend("analyzeImageWithIntent", imagePath, prompt)).strip()
+ analysis = stripThinkingContent(rawAnalysis)
+
+ if len(analysis) == 0:
+ raise Error("识图模型返回了空结果。")
+ if re.match(r"^Image recognition failed:", rawAnalysis, re.IGNORECASE) or re.match(r"^Image recognition failed:", analysis, re.IGNORECASE):
+ raise Error(rawAnalysis or analysis)
+
+ return analysis
+
+
+async def processSinglePage(pdfFile, outputDir, pageNumber, prompt, service):
+ console.log(f"[{TOOL_NAME}] 开始处理第 {pageNumber} 页")
+
+ zeroBasedPageIndex = pageNumber - 1
+ imageFile = File(outputDir, f"page-{padPageNumber(pageNumber)}.{IMAGE_FORMAT}")
+ imagePath = str(imageFile.getAbsolutePath())
+
+ fileDescriptor = None
+ pdfRenderer = None
+ page = None
+ bitmap = None
+ outputStream = None
+ stage = "render"
+
+ try:
+ fileDescriptor = ParcelFileDescriptor.open(pdfFile, ParcelFileDescriptor.MODE_READ_ONLY)
+ pdfRenderer = PdfRenderer(fileDescriptor)
+ page = pdfRenderer.openPage(zeroBasedPageIndex)
+
+ width = int(Number(page.getWidth()) * RENDER_SCALE)
+ height = int(Number(page.getHeight()) * RENDER_SCALE)
+
+ bitmap = Bitmap.createBitmap(width, height, BitmapConfig.ARGB_8888)
+ bitmap.eraseColor(Color.WHITE)
+ page.render(bitmap, None, None, PdfRendererPage.RENDER_MODE_FOR_DISPLAY)
+
+ outputStream = FileOutputStream(imageFile)
+ compressed = bitmap.compress(BitmapCompressFormat.PNG, 100, outputStream)
+ if not compressed:
+ raise Error(f"页面 {pageNumber} 图片写入失败。")
+
+ safeClose(outputStream, f"page-{pageNumber} output stream")
+ outputStream = None
+
+ imageLink = str(NativeInterface.registerImageFromPath(imagePath) or "").strip()
+ if not imageLink:
+ raise Error(f"页面 {pageNumber} 图片链接注册失败。")
+
+ stage = "vision"
+ analysis = await analyzePageImage(service, imagePath, prompt)
+ pageOutputPath = writePageOutputFile(outputDir, pageNumber, analysis)
+
+ console.log(f"[{TOOL_NAME}] 第 {pageNumber} 页处理完成")
+ return {
+ "page_number": pageNumber,
+ "image_path": imagePath,
+ "image_link": imageLink,
+ "output_path": pageOutputPath,
+ "analysis": analysis,
+ }
+ except Exception as error:
+ raise PageProcessingFailure(
+ page_number=pageNumber,
+ stage=stage,
+ error=normalizeErrorMessage(error),
+ partial_results=[],
+ )
+ finally:
+ safeClose(outputStream, f"page-{pageNumber} output stream")
+ safeRecycle(bitmap)
+ safeClose(page, f"page-{pageNumber}")
+ safeClose(pdfRenderer, f"page-{pageNumber} pdfRenderer")
+ safeClose(fileDescriptor, f"page-{pageNumber} parcelFileDescriptor")
+
+
+class PageProcessingFailure(Exception):
+ def __init__(self, page_number, stage, error, partial_results=None):
+ self.page_number = page_number
+ self.stage = stage
+ self.error = error
+ self.partial_results = partial_results or []
+ super().__init__(error)
+
+
+async def processPagesWithConcurrency(pageNumbers, limit, worker):
+ results = [None] * len(pageNumbers)
+ nextIndex = [0]
+ failure = [None]
+
+ async def runWorker():
+ while True:
+ if failure[0]:
+ return
+
+ currentIndex = nextIndex[0]
+ nextIndex[0] += 1
+
+ if currentIndex >= len(pageNumbers):
+ return
+
+ pageNumber = pageNumbers[currentIndex]
+ try:
+ results[currentIndex] = await worker(pageNumber)
+ except Exception as error:
+ if not failure[0]:
+ if isinstance(error, PageProcessingFailure):
+ failure[0] = {
+ "page_number": error.page_number,
+ "stage": error.stage,
+ "error": normalizeErrorMessage(error.error),
+ }
+ else:
+ failure[0] = {
+ "page_number": pageNumber,
+ "stage": "render",
+ "error": normalizeErrorMessage(error),
+ }
+ return
+
+ workerCount = max(1, min(limit, len(pageNumbers)))
+ await Promise.all([runWorker() for _ in range(workerCount)])
+
+ partialResults = sortPageResults([page for page in results if page])
+ if failure[0]:
+ failureInfo = failure[0]
+ raise PageProcessingFailure(
+ page_number=failureInfo["page_number"],
+ stage=failureInfo["stage"],
+ error=failureInfo["error"],
+ partial_results=partialResults,
+ )
+
+ return partialResults
+
+
+async def parsePdfWithVisionInternal(params):
+ try:
+ binding = await Tools.SoftwareSettings.getFunctionModelConfig(TOOL_FUNCTION_TYPE)
+ assertVisionBinding(binding)
+ except Exception as error:
+ message = str(error.message if isinstance(error, Exception) else error)
+ return buildPrecheckFailure(message)
+
+ visionModel = buildVisionModelInfo(binding)
+ rawPdfPath = toTrimmedString(params.get("pdf_path") if params else None)
+
+ resolvedPrompt = toTrimmedString(params.get("page_prompt") if params else None) or DEFAULT_PAGE_PROMPT
+
+ try:
+ pdfFile = validatePdfFile(rawPdfPath)
+ startPage = parsePositiveInteger(params.get("start_page") if params else None, "start_page", {"allowUndefined": True}) or 1
+ endPageInput = parsePositiveInteger(params.get("end_page") if params else None, "end_page", {"allowUndefined": True})
+ except Exception as error:
+ message = str(error.message if isinstance(error, Exception) else error)
+ return {
+ "success": False,
+ "message": message,
+ "data": {
+ "pdf_path": rawPdfPath,
+ "image_dir": "",
+ "output_path": "",
+ "total_page_count": 0,
+ "processed_page_count": 0,
+ "pages": [],
+ "vision_model": visionModel,
+ "failed_page": {
+ "page_number": 0,
+ "stage": "precheck",
+ "error": message,
+ },
+ },
+ }
+
+ pages = []
+ context = resolveAppContext()
+ enhancedAiService = EnhancedAIService.getInstance(context)
+ outputDir = None
+ imageDir = ""
+ outputPath = ""
+
+ fileDescriptor = None
+ pdfRenderer = None
+ totalPageCount = 0
+
+ try:
+ fileDescriptor = ParcelFileDescriptor.open(pdfFile, ParcelFileDescriptor.MODE_READ_ONLY)
+ pdfRenderer = PdfRenderer(fileDescriptor)
+ totalPageCount = int(Number(pdfRenderer.getPageCount()))
+
+ if not isinstance(totalPageCount, int) or totalPageCount <= 0:
+ error = "PDF 没有可处理的页面。"
+ return {
+ "success": False,
+ "message": error,
+ "data": buildData(rawPdfPath, imageDir, outputPath, 0, pages, visionModel, {
+ "page_number": 0,
+ "stage": "render",
+ "error": error,
+ }),
+ }
+
+ resolvedEndPage = min(endPageInput if endPageInput is not None else totalPageCount, totalPageCount)
+ if (startPage or 1) > resolvedEndPage:
+ error = (
+ f"start_page 超出了总页数,文档总页数为 {totalPageCount}。"
+ if (startPage or 1) > totalPageCount
+ else "start_page 不能大于 end_page。"
+ )
+ return {
+ "success": False,
+ "message": error,
+ "data": buildData(rawPdfPath, imageDir, outputPath, totalPageCount, pages, visionModel, {
+ "page_number": 0,
+ "stage": "precheck",
+ "error": error,
+ }),
+ }
+
+ outputDir = createOutputDirectory()
+ imageDir = str(outputDir.getAbsolutePath())
+ pageNumbers = []
+ pageNumber = startPage or 1
+ while pageNumber <= resolvedEndPage:
+ pageNumbers.append(pageNumber)
+ pageNumber += 1
+
+ try:
+ async def _worker(currentPageNumber):
+ return await processSinglePage(pdfFile, outputDir, currentPageNumber, resolvedPrompt, enhancedAiService)
+
+ processedPages = await processPagesWithConcurrency(pageNumbers, PAGE_CONCURRENCY, _worker)
+ pages.extend(processedPages)
+ except Exception as error:
+ failedPage = error
+ partialResults = getattr(failedPage, "partial_results", []) or []
+ pages.extend(sortPageResults(partialResults))
+ outputPath = writeMergedOutputFile(outputDir, pages)
+
+ return {
+ "success": False,
+ "message": f"第 {failedPage.page_number} 页处理失败: {failedPage.error}",
+ "data": buildData(rawPdfPath, imageDir, outputPath, totalPageCount, pages, visionModel, {
+ "page_number": failedPage.page_number,
+ "stage": failedPage.stage,
+ "error": failedPage.error,
+ }),
+ }
+
+ outputPath = writeMergedOutputFile(outputDir, pages)
+ return {
+ "success": True,
+ "message": f"成功解析 {len(pages)} 页 PDF 并完成识图,结果已写入 {outputPath}。",
+ "data": buildData(rawPdfPath, imageDir, outputPath, totalPageCount, pages, visionModel),
+ }
+ except Exception as error:
+ message = str(error.message if isinstance(error, Exception) else error)
+ outputPath = writeMergedOutputFile(outputDir, pages)
+ return {
+ "success": False,
+ "message": message,
+ "data": buildData(rawPdfPath, imageDir, outputPath, totalPageCount, pages, visionModel, {
+ "page_number": 0,
+ "stage": "render",
+ "error": message,
+ }),
+ }
+ finally:
+ safeClose(pdfRenderer, "pdfRenderer")
+ safeClose(fileDescriptor, "parcelFileDescriptor")
+
+
+async def wrapToolExecution(func, params):
+ try:
+ result = await func(params if params else {})
+ complete(result)
+ except Exception as error:
+ message = str(error.message if isinstance(error, Exception) else error)
+ complete({
+ "success": False,
+ "message": message,
+ "data": {
+ "pdf_path": toTrimmedString(params.get("pdf_path") if params else None),
+ "image_dir": "",
+ "output_path": "",
+ "total_page_count": 0,
+ "processed_page_count": 0,
+ "pages": [],
+ "vision_model": {
+ "function_type": TOOL_FUNCTION_TYPE,
+ "config_id": "",
+ "config_name": "",
+ "selected_model": "",
+ },
+ "failed_page": {
+ "page_number": 0,
+ "stage": "precheck",
+ "error": message,
+ },
+ },
+ })
+
+
+async def parse_pdf_with_vision(params):
+ await wrapToolExecution(parsePdfWithVisionInternal, params)
+
+
+async def main(params):
+ await wrapToolExecution(parsePdfWithVisionInternal, params)
+
+
+exports.parse_pdf_with_vision = parse_pdf_with_vision
+exports.main = main
diff --git a/operit_py/qq_intelligent.py b/operit_py/qq_intelligent.py
new file mode 100644
index 0000000..b70f96b
--- /dev/null
+++ b/operit_py/qq_intelligent.py
@@ -0,0 +1,337 @@
+# METADATA
+# {
+# "name": "Experimental_qq_intelligent",
+# "display_name": {
+# "zh": "实验性 QQ 智能助手",
+# "en": "Experimental QQ Intelligent Assistant"
+# },
+# "description": { "zh": "高级QQ智能助手,通过UI自动化技术实现QQ应用交互,支持消息自动回复、历史记录读取、联系人搜索与通讯等功能,为AI赋予QQ社交能力。适用于智能客服、自动回复、社交辅助等场景。", "en": "Advanced QQ assistant powered by UI automation. Supports sending messages, reading chat history, searching contacts/groups, and basic communication workflows, enabling AI-driven social messaging scenarios." },
+# "category": "Media",
+# "tools": [
+# {
+# "name": "reply",
+# "description": { "zh": "在当前聊天窗口输入消息并发送。一般情况下,用户想要ai帮忙发送消息时,需要ai自己去生成回复的消息,如果不确定发送的内容,请不要调用工具。只要是停留在聊天界面,就可以直接调用这个。", "en": "Type and send a message in the current chat window. Usually the AI should generate the message content by itself; if you are unsure what to send, do not call this tool. As long as you are staying on a chat page, you can call it directly." },
+# "parameters": [
+# { "name": "message", "description": { "zh": "要发送的消息", "en": "Message text to send." }, "type": "string", "required": true },
+# { "name": "click_send", "description": { "zh": "是否点击发送按钮", "en": "Whether to tap the Send button." }, "type": "boolean", "required": false }
+# ]
+# },
+# {
+# "name": "find_user",
+# "description": { "zh": "在QQ联系人或群成员中查找用户", "en": "Find a user in QQ contacts or group chats." },
+# "parameters": [
+# { "name": "user_name", "description": { "zh": "搜索用户名称", "en": "User name keyword to search." }, "type": "string", "required": true },
+# { "name": "user_type", "description": { "zh": "搜索类型(contacts/groups)", "en": "Search type: contacts or groups." }, "type": "string", "required": true }
+# ]
+# },
+# {
+# "name": "find_and_reply",
+# "description": { "zh": "查找用户并发送消息", "en": "Find a user and send a message." },
+# "parameters": [
+# { "name": "message", "description": { "zh": "要发送的消息", "en": "Message text to send." }, "type": "string", "required": true },
+# { "name": "user_name", "description": { "zh": "发送目标用户名称", "en": "Target user name keyword." }, "type": "string", "required": true },
+# { "name": "user_type", "description": { "zh": "用户类型(contacts/groups)", "en": "Target type: contacts or groups." }, "type": "string", "required": true },
+# { "name": "click_send", "description": { "zh": "是否点击发送按钮", "en": "Whether to tap the Send button." }, "type": "boolean", "required": false }
+# ]
+# },
+# {
+# "name": "get_history",
+# "description": { "zh": "获取当前聊天窗口的历史消息。如果用户要求读取聊天记录,那么可以用这个工具或者find_and_get_history。", "en": "Get chat history from the current chat window. If the user asks to read chat history, you can use this tool or `find_and_get_history`." },
+# "parameters": [
+# { "name": "message_num", "description": { "zh": "获取的消息数量", "en": "Number of messages to retrieve." }, "type": "number", "required": false }
+# ]
+# },
+# {
+# "name": "find_and_get_history",
+# "description": { "zh": "查找用户并获取聊天历史记录。一般情况下,用户想要让ai帮忙回消息的时候,需要先调用这个,得到历史记录后直接调用reply。", "en": "Find a user and retrieve chat history. Typically, when the user wants the AI to reply, call this first to get context, then call `reply`." },
+# "parameters": [
+# { "name": "user_name", "description": { "zh": "搜索用户名称", "en": "User name keyword to search." }, "type": "string", "required": true },
+# { "name": "user_type", "description": { "zh": "搜索类型(contacts/groups)", "en": "Search type: contacts or groups." }, "type": "string", "required": true },
+# { "name": "message_num", "description": { "zh": "获取的消息数量", "en": "Number of messages to retrieve." }, "type": "number", "required": false }
+# ]
+# }
+# ]
+# }
+
+
+def _at(arr, index):
+ if index < 0:
+ index = len(arr) + index
+ if 0 <= index < len(arr):
+ return arr[index]
+ return None
+
+
+async def close_keyboard():
+ await Tools.UI.pressKey("KEYCODE_BACK")
+
+
+async def is_in_group():
+ page = await UINode.getCurrentPage()
+ elements = page.findAllByContentDesc('语音')
+ idx = -1
+ for i, e in enumerate(elements):
+ className = e.className or ""
+ center = e.centerPoint or {}
+ if 'ImageButton' in className and (center.get('y') or 0) > 1000:
+ idx = i
+ break
+ return idx == -1
+
+
+async def get_history(params):
+ message_num = int(params.get("message_num")) if params.get("message_num") else 10
+ if not message_num:
+ message_num = 10
+ page = await UINode.getCurrentPage()
+
+ # 获取群名称
+ title_node = page.findById('com.tencent.mobileqq:id/ivTitleBtnLeft')
+ chat_title = ""
+ if title_node is not None:
+ parent = getattr(title_node, "parent", None)
+ if parent is not None:
+ texts = parent.allTexts()
+ chat_title = texts[0] if texts else ""
+ console.log("chat_title", chat_title)
+
+ # 先滑动到最底部
+ tryMax = 0
+ while tryMax < 1:
+ tryMax += 1
+ await Tools.UI.swipe(100, 1000, 100, 200)
+ await Tools.System.sleep(500)
+
+ messageList = []
+ allMessages = []
+
+ # 获取历史消息
+ tryMax = 0
+ while tryMax < 5:
+ tryMax += 1
+
+ page = await UINode.getCurrentPage()
+ console.log("page", page.toFormattedString())
+ list_view = page.findByClass('RecyclerView')
+ if not list_view:
+ return None
+
+ # 清空临时消息列表
+ messageList = []
+
+ # 获取当前可见的消息列表
+ for message in list_view.children:
+ message_text = '/'.join(message.allTexts())
+
+ sender = "other"
+ # 判断头像在左边还是右边
+ avatar = message.findByClass('ImageView')
+ if avatar:
+ center = avatar.centerPoint or {}
+ if (center.get('x') or 0) < 300:
+ sender = "other"
+ console.log("other", center.get('x'))
+ else:
+ sender = "self"
+ console.log("self", center.get('x'))
+ else:
+ sender = "self"
+ console.log("self")
+ messageList.append({"message": message_text, "sender": sender})
+
+ # 将当前视图中的消息添加到总集合中,保持顺序
+ # 从下往上滑动时,将新获取的消息放在前面(保持旧消息在前,新消息在后的顺序)
+ allMessages = messageList + allMessages
+
+ # 如果已经获取了足够多的消息,停止滚动
+ if len(allMessages) >= message_num:
+ break
+ await Tools.UI.swipe(100, 300, 100, 1000)
+ await Tools.System.sleep(500)
+
+ # 删除重复的消息,但保持原始顺序
+ uniqueMessages = []
+ seen = set()
+
+ for msg in allMessages:
+ key = f"{msg['message']}-{msg['sender']}"
+ if key not in seen:
+ seen.add(key)
+ uniqueMessages.append(msg)
+
+ # 返回符合数量要求的消息列表(保持原始顺序)
+ return {"messages": uniqueMessages[:message_num], "chat_title": chat_title}
+
+
+async def reply(params):
+ # 提取参数
+ message = params.get("message") or ""
+ click_send = params.get("click_send") or False
+ await Tools.UI.setText(message)
+ await Tools.System.sleep(500)
+
+ if click_send:
+ await Tools.UI.clickElement({
+ "resourceId": "com.tencent.mobileqq:id/send_btn",
+ "index": "0"
+ })
+ return True
+
+
+async def find_and_reply(params):
+ # 提取参数
+ message = params.get("message") or ""
+ user_name = params.get("user_name") or ""
+ user_type = params.get("user_type") or "contacts"
+ click_send = params.get("click_send") or False
+ result = await find_user({"user_name": user_name, "user_type": user_type})
+ if not result:
+ return False
+ await Tools.System.sleep(1000)
+ return await reply({"message": message, "click_send": click_send})
+
+
+async def ensureActivity(activityName="", packageName="com.tencent.mobileqq", enterActivity=None, tryMax=1):
+ if enterActivity is None:
+ async def enterActivity():
+ return True
+ android = Android()
+ activity = await Tools.UI.getPageInfo()
+ if (activity.get("activityName") or "").find(activityName) != -1 if activityName else False:
+ return True
+
+ while tryMax > 0:
+ tryMax -= 1
+ await Tools.System.stopApp(packageName)
+ await Tools.System.sleep(2000)
+ await Tools.System.startApp(packageName)
+ await Tools.System.sleep(3000) # Give some time for app to launch
+ if await enterActivity():
+ activity = await Tools.UI.getPageInfo()
+ if (activity.get("activityName") or "").find(activityName) != -1 if activityName else False:
+ return True
+ return False
+
+
+async def find_user(params):
+ # 提取参数
+ user_name = params.get("user_name") or ""
+ user_type = params.get("user_type") or "contacts"
+
+ async def _enter_activity():
+ search_btn = (await UINode.getCurrentPage()).findByText("搜索")
+ if search_btn:
+ await search_btn.click()
+ return True
+ return False
+
+ if not await ensureActivity("com.tencent.mobileqq.search.activity.UniteSearchActivity", "com.tencent.mobileqq", _enter_activity):
+ return False
+
+ firstTarget = None
+ tryMax = 0
+ while tryMax < 2:
+ tryMax += 1
+ search_btn = (await UINode.getCurrentPage()).findByText("搜索")
+ if search_btn:
+ await search_btn.click()
+ await Tools.System.sleep(500)
+ # 输入搜索内容
+ await Tools.UI.setText(user_name)
+ await Tools.System.sleep(3000 * tryMax)
+ await close_keyboard()
+
+ currentPage = await UINode.getCurrentPage()
+ searchResult = currentPage.findAllById('com.tencent.mobileqq:id/title')
+
+ isNeedToCatch = False
+ for child in (searchResult or []):
+ if isNeedToCatch:
+ firstTarget = child
+ break
+ title = child
+ if title:
+ if user_type == "contacts" and title.text == "联系人":
+ isNeedToCatch = True
+ elif user_type == "groups" and title.text == "群聊":
+ isNeedToCatch = True
+
+ if firstTarget:
+ await firstTarget.click()
+ return True
+
+ return False
+
+
+async def find_and_get_history(params):
+ user_name = params.get("user_name") or ""
+ user_type = params.get("user_type") or "contacts"
+ message_num = params.get("message_num") or 10
+ result = await find_user({"user_name": user_name, "user_type": user_type})
+ if not result:
+ return None
+ await Tools.System.sleep(1000)
+ return await get_history({"message_num": message_num})
+
+
+async def main():
+ result = await find_and_get_history({"user_name": "Dec", "user_type": "groups", "message_num": 20})
+ console.log(result)
+ complete({
+ "success": result
+ })
+
+
+async def wrap_bool(func, params, successMessage, failMessage, additionalMessage=""):
+ if await func(params):
+ complete({
+ "success": True,
+ "message": successMessage,
+ "additionalMessage": additionalMessage
+ })
+ else:
+ complete({
+ "success": False,
+ "message": failMessage,
+ "additionalMessage": additionalMessage
+ })
+
+
+async def wrap_data(func, params, successMessage, failMessage, additionalMessage=""):
+ result = await func(params)
+ complete({
+ "success": True,
+ "message": successMessage,
+ "additionalMessage": additionalMessage,
+ "data": result
+ })
+
+
+async def _exported_reply(params):
+ await wrap_bool(reply, params, "发送成功", "发送失败")
+
+
+async def _exported_find_user(params):
+ additionalMessage = (await UINode.getCurrentPage()).toFormattedString()
+ await wrap_bool(find_user, params, "查找成功", "查找失败,停留在界面", additionalMessage)
+
+
+async def _exported_find_and_reply(params):
+ additionalMessage = (await UINode.getCurrentPage()).toFormattedString()
+ await wrap_bool(find_and_reply, params, "发送成功", "发送失败,停留在界面", additionalMessage)
+
+
+async def _exported_get_history(params):
+ await wrap_data(get_history, params, "获取历史消息成功", "获取历史消息失败")
+
+
+async def _exported_find_and_get_history(params):
+ await wrap_data(find_and_get_history, params, "获取历史消息成功", "获取历史消息失败")
+
+
+exports.reply = _exported_reply
+exports.find_user = _exported_find_user
+exports.find_and_reply = _exported_find_and_reply
+exports.get_history = _exported_get_history
+exports.find_and_get_history = _exported_find_and_get_history
+exports.main = main
diff --git a/operit_py/quick_start.py b/operit_py/quick_start.py
new file mode 100644
index 0000000..beac27c
--- /dev/null
+++ b/operit_py/quick_start.py
@@ -0,0 +1,410 @@
+# 欢迎来到 Python AI 工具开发终极入门教程 (v2.0 详细版)
+# ====================================================================
+#
+# 本教程为初学者设计,特别是那些熟悉其他脚本语言但对 Python 不熟悉的开发者。
+# 我们将从最基础的语法开始,一步步带你构建一个功能完整、结构优雅的AI工具,并详细解释每一步背后的"为什么"。
+#
+# 注:本文件由 TypeScript 版本翻译而来,保留了原有的教学结构与注释。
+
+
+# =================================================================
+# Part 0: 关于运行环境 (A Note on the Environment)
+# =================================================================
+#
+# 在开始之前,你需要知道,你的代码并不是在真空中运行。它在一个特殊的"沙箱"环境中执行。
+# 这个环境为你预先提供了一些全局可用的函数和对象,最重要的有:
+#
+# 1. `complete(result: dict)`:
+# - 这是你的工具与AI系统沟通的【唯一】桥梁。
+# - 当你的工具执行完毕,无论成功还是失败,都必须调用这个函数来返回结果。
+# - AI会根据你传入这个函数的对象内容,来决定下一步的行为。
+#
+# 2. `exports`:
+# - 这是一个特殊的对象,你可以把它看作是你代码文件的"公开接口"。
+# - 你需要将你的工具函数"挂载"到 `exports` 对象上,AI才能找到并调用它们。
+# - 示例: `exports.myToolName = myToolFunction`
+#
+# 3. `Tools`:
+# - 这是一个内置的工具集,提供了很多实用的辅助功能,比如文件读写、网络请求、系统命令等。
+# - 我们在本教程中会用到 `Tools.System.sleep()` 来演示异步操作。
+
+
+# =================================================================
+# Part 1: Python 核心语法快速入门
+# =================================================================
+
+# --- 1.1 变量声明 (Variables) ---
+def variable_example():
+ tool_name = "My Greeter Tool" # 一旦设定,tool_name 就不能再被赋值(对应 TS 的 const)
+ execution_count = 0 # execution_count 的值可以改变(对应 TS 的 let)
+ console.log(f"工具名称: {tool_name}")
+
+ execution_count = execution_count + 1
+ console.log(f"这是第 {execution_count} 次执行。")
+
+ # 下面这行代码会报错,因为 tool_name 是一个常量
+ # tool_name = "New Name"
+
+
+# --- 1.2 数据类型 (Data Types) ---
+def data_types_example():
+ # Python 是动态类型语言,无需显式声明类型
+ user_name = "Alice"
+ user_age = 30
+ is_active = True
+
+ # 对象(字典),键值对的集合
+ user_info = {
+ "name": "Bob",
+ "age": 25,
+ "premium": False
+ }
+ console.log(f"{user_info['name']} 的年龄是 {user_info['age']}")
+
+ # 列表(对应 JS 的数组)
+ permissions = ["read", "write", "execute"]
+ console.log(f"用户的第一个权限是: {permissions[0]}")
+
+ # 遍历列表
+ for permission in permissions:
+ console.log(f"权限: {permission}")
+
+
+# --- 1.3 函数 (Functions) 与 异步操作 (Async Operations) ---
+# - `async def`: 这是定义AI工具函数的标准方式。`async` 关键字表明该函数内部可能包含需要"等待"的操作。
+# - `await`: 必须在 `async` 函数内部使用。它会暂停函数的执行,直到其后的异步操作完成,然后返回结果。
+async def async_function_example(name):
+ console.log(f"开始向 {name} 发送问候...")
+
+ # `await` 在这里暂停函数,等待 Tools.System.sleep(1500) 完成
+ # 这模拟了一个耗时1.5秒的网络请求或文件操作
+ await Tools.System.sleep(1500)
+
+ greeting_message = f"你好, {name}! 异步问候已送达。"
+ console.log(greeting_message)
+
+ # async 函数通过 return 返回的值
+ return greeting_message
+
+
+# --- 1.4 错误处理 (Error Handling) ---
+# - `try...except`: 这是处理潜在错误的标准方式,对于健壮的工具至关重要。
+# - `raise Exception(...)`: 当函数执行不下去时,主动"抛出"一个错误,中断当前 `try` 块的执行,并被 `except` 块捕获。
+def error_handling_example(user_input):
+ try:
+ console.log("尝试处理输入...")
+
+ # 步骤1: 检查输入是否为非空字符串
+ if not isinstance(user_input, str) or user_input.strip() == '':
+ # 如果检查失败,就"抛出"一个错误对象
+ raise Exception("输入必须是一个非空的字符串。")
+
+ # 步骤2: 如果代码能执行到这里,说明没有错误发生
+ console.log(f"输入有效,内容是: \"{user_input}\"")
+ return {"success": True, "data": user_input}
+
+ except Exception as error:
+ # 步骤3: 如果 `try` 块中任何地方抛出了错误,程序会立即跳转到这里
+ console.error("发生了一个错误!")
+ console.error(f"错误详情: {str(error)}") # str(error) 包含了我们抛出时提供的信息
+
+ # 在实际工具中,这里我们会调用 complete() 来报告失败
+ return {"success": False, "message": str(error)}
+
+
+# =================================================================
+# Part 2: AI工具的本质 与 `complete()` 函数
+# =================================================================
+#
+# 如前所述,一个AI工具就是一个函数,它通过 `complete()` 函数与AI系统交互。
+# 下面是一个最基础的、结构完整的AI工具示例,我们为其中的关键部分添加了注释。
+async def simple_greeter_tool(params):
+ # 典型的工具函数总是在一个大的 try...except 块中
+ try:
+ # 1. 从 AI 接收到的参数中解构出需要的值
+ user_name = params.get("user_name")
+
+ # 2. 参数校验:永远不要相信输入!
+ if not user_name:
+ raise Exception("参数 'user_name' 缺失,无法生成问候语。")
+
+ # 3. 执行核心业务逻辑
+ greeting = f"你好, {user_name}! 欢迎来到AI工具的世界。"
+
+ # 4. 调用 complete(),报告成功
+ # - success: True 表示工具成功完成了它的任务。
+ # - data: 存放工具的执行结果,这是AI最关心的部分。
+ complete({
+ "success": True,
+ "data": greeting
+ })
+
+ except Exception as error:
+ # 5. 如果 try 块中任何地方出错(无论是参数校验还是业务逻辑),都会进入这里
+ console.error("simple_greeter_tool 执行失败:", error)
+
+ # 6. 调用 complete(),报告失败
+ # - success: False 告诉AI任务失败了。
+ # - message: 存放清晰的错误信息,帮助AI理解失败原因。
+ complete({
+ "success": False,
+ "message": f"工具执行失败: {str(error)}"
+ })
+
+
+# =================================================================
+# Part 3: Metadata 和 函数导出 - 连接AI与代码的桥梁
+# =================================================================
+#
+# 我们已经写好了工具函数,但AI如何知道它的存在、功能和用法呢?
+# 这需要两样东西:
+# 1. `METADATA` (元数据): 就像是工具的"说明书"。
+# 2. `exports` (导出): 像是把工具函数"注册"到系统里。
+#
+# --- 3.1 详解 METADATA ---
+# 文件顶部的 `# METADATA ...` 注释块就是这份说明书。AI在加载时会解析它。
+# - `name`: (string) 工具集的唯一ID。
+# - `description`: (string) 对整个工具集的详细描述。
+# - `tools`: (array) 一个数组,包含此文件中所有可用的工具。
+# - `name`: (string) 单个工具的函数名。这个名字【必须】和后面 `exports` 中使用的名字完全一致。
+# - `description`: (string) 对这个工具能做什么的清晰描述。AI会根据这个描述来决定何时调用它。
+# - `parameters`: (array) 定义了此工具需要哪些参数。
+# - `name`: (string) 参数名。
+# - `description`: (string) 对参数用途的描述。
+# - `type`: (string) 参数的类型 (e.g., 'string', 'number', 'boolean')。
+# - `required`: (boolean) 这个参数是否是必需的。
+
+# METADATA
+# {
+# "name": "greeter_tool_v2",
+# "display_name": {
+# "zh": "问候工具 V2",
+# "en": "Greeting Tool V2"
+# },
+# "description": { "zh": "一个提供多种问候方式的教学工具集。", "en": "A tutorial tool package that demonstrates multiple greeting methods." },
+# "category": "Utility",
+# "tools": [
+# {
+# "name": "greet",
+# "description": { "zh": "向指定的人发送一个标准的问候。", "en": "Send a standard greeting to a specified person." },
+# "parameters": [
+# {
+# "name": "user_name",
+# "description": { "zh": "要问候的人的名字。", "en": "Name of the person to greet." },
+# "type": "string",
+# "required": true
+# }
+# ]
+# },
+# {
+# "name": "system_inspector",
+# "description": { "zh": "获取设备摘要信息,并在用户的HOME目录下查找符合特定模式的文件。", "en": "Get device summary information and find files matching a pattern under the user's HOME directory." },
+# "parameters": [
+# {
+# "name": "search_pattern",
+# "description": { "zh": "用于文件搜索的通配符模式,例如:'*.txt' 或 'documents/*.pdf'。", "en": "Glob pattern for file search, e.g. '*.txt' or 'documents/*.pdf'." },
+# "type": "string",
+# "required": true
+# }
+# ]
+# }
+# ]
+# }
+
+# --- 3.2 详解 exports ---
+#
+# `exports` 是一个空对象,等待你来填充。
+# 你必须将你的工具函数赋值给 `exports` 对象的一个属性,属性名必须和 `METADATA` 中定义的工具 `name` 完全一致。
+# 只有这样,AI才能把"说明书"和"实际的函数实现"关联起来。
+#
+# `exports.greet = simple_greeter_tool`
+#
+# 上面这行代码的意思是:
+# "当AI想要调用名为 'greet' 的工具时,请执行 `simple_greeter_tool` 这个函数。"
+# (我们将在教程的最后进行统一导出,所以暂时注释掉这行)
+
+
+# =================================================================
+# Part 4: 模块作用域 - 隔离代码,避免冲突
+# =================================================================
+#
+# 在 Python 中,每个 .py 文件本身就是一个模块,天然拥有独立的作用域。
+# 在文件顶层声明的变量和函数不会污染其他模块,因此不需要像 JS 那样使用 IIFE。
+# 我们可以通过定义函数来组织"私有"实现,再选择性地导出需要公开的接口。
+
+# 使用模块作用域重构我们的问候工具
+GREETER_VERSION = "2.0"
+
+
+def log_with_timestamp(message):
+ import datetime
+ timestamp = datetime.datetime.now().strftime("%H:%M:%S")
+ console.log(f"[{timestamp}][v{GREETER_VERSION}] {message}")
+
+
+# 这是我们真正的业务逻辑实现
+async def perform_greeting(params):
+ try:
+ import json
+ log_with_timestamp(f"开始执行问候工具,参数: {json.dumps(params, ensure_ascii=False)}")
+ user_name = params.get("user_name")
+ if not user_name:
+ raise Exception("参数 'user_name' 缺失。")
+ greeting = f"你好, {user_name}! (来自模块模式 v{GREETER_VERSION})"
+ complete({"success": True, "data": greeting})
+ except Exception as error:
+ log_with_timestamp(f"问候工具失败: {str(error)}")
+ complete({"success": False, "message": str(error)})
+
+
+# =================================================================
+# Part 5: Wrapper模式 - 终极形态,让代码更健壮、更简洁
+# =================================================================
+#
+# 观察上面的代码,即使使用了模块作用域,`perform_greeting` 函数内部仍然有重复的 `try/except` 和 `complete()` 调用。
+# 在软件工程中,重复的代码(boilerplate)是维护的噩梦。
+#
+# 我们可以创建一个 `wrapper` (包装器) 函数来终结这种重复。
+# Wrapper 的职责非常清晰:
+# 1. 接收一个"核心业务逻辑函数"作为输入。
+# 2. 在内部建立 `try/except` 安全网。
+# 3. 负责调用核心函数,并等待其结果。
+# 4. 无论成功还是失败,都由它来调用 `complete()` 并返回标准格式的结果。
+#
+# 这样,我们编写的工具函数就可以变得非常纯粹:只关心输入、业务处理和输出(通过 `return` 和 `raise`),
+# 完全不用理会 `try/except` 和 `complete` 这些模板代码。
+
+# --- 最终的、最推荐的开发模式 ---
+
+
+# 1. 定义我们的通用 Wrapper 函数
+# 它接收一个核心逻辑函数和其所需的参数。
+async def wrap(core_function, params):
+ try:
+ # 调用核心业务逻辑,并使用 await 等待其完成
+ result = await core_function(params)
+
+ # 核心逻辑成功返回,Wrapper负责包装成标准成功对象并调用complete
+ complete({
+ "success": True,
+ "message": "工具执行成功。",
+ "data": result # 将核心函数的返回值放入data字段
+ })
+
+ except Exception as error:
+ import traceback
+ console.error(f"工具 [{core_function.__name__}] 执行时捕获到错误:", error)
+
+ # 核心逻辑抛出异常,Wrapper负责捕获并包装成标准失败对象
+ complete({
+ "success": False,
+ "message": f"错误: {str(error)}", # 将错误信息放入message字段
+ "error_stack": traceback.format_exc() # 附带完整的错误堆栈以供调试
+ })
+
+
+# =================================================
+# 核心业务逻辑区 (Core Logic Functions)
+# =================================================
+
+# 2a. "问候"工具的核心逻辑
+async def greet_core_logic(params):
+ user_name = params.get("user_name")
+ if not user_name or user_name.strip() == '':
+ raise Exception("user_name 不能为空。")
+ greeting = f"你好, {user_name}! 这是一个采用终极Wrapper模式的工具。"
+ return greeting
+
+
+# 2b. "系统检查器"工具的核心逻辑
+async def inspect_system_core_logic(params):
+ search_pattern = params.get("search_pattern")
+ if not search_pattern:
+ raise Exception("search_pattern 不能为空。")
+
+ console.log(f"开始系统检查,搜索模式: '{search_pattern}'")
+
+ # 为了让逻辑更清晰,我们一步一步地、顺序地调用工具
+ # 1. 首先,获取设备信息
+ console.log("正在获取设备信息...")
+ device_info = await Tools.System.getDeviceInfo()
+ console.log("设备信息获取成功。")
+
+ # 2. 然后,查找文件
+ console.log(f"正在主目录 ('~') 下查找文件,模式: '{search_pattern}'")
+ found_files = await Tools.Files.find('~', search_pattern)
+ console.log("文件查找成功。")
+
+ # 组合结果并返回一个结构化的对象
+ return {
+ "device": device_info,
+ "files": found_files
+ }
+
+
+# =================================================
+# 导出函数封装区 (Exported Functions)
+# =================================================
+
+# 3a. 暴露给AI的"问候"函数
+async def greet_exported_function(params):
+ await wrap(greet_core_logic, params)
+
+
+# 3b. 暴露给AI的"系统检查器"函数
+async def inspect_system_exported_function(params):
+ await wrap(inspect_system_core_logic, params)
+
+
+# =================================================================
+# Part 6: 使用内置工具集 (Using the Built-in `Tools`)
+# =================================================================
+#
+# 沙箱环境提供了一个强大的全局对象 `Tools`,它是你与外部世界交互的主要入口。
+# `Tools` 对象被组织成多个模块,每个模块负责一类特定的功能。
+#
+# 主要的模块包括:
+# - `Tools.System`: 系统级操作。
+# - `getDeviceInfo()`: 获取设备信息(型号、操作系统版本等)。
+# - `sleep(ms)`: 让工具暂停指定的毫秒数。
+# - `shell(command)`: 执行shell命令。
+# - `Tools.Files`: 文件系统操作。
+# - `read(path)`: 读取文件内容。
+# - `write(path, content)`: 写入内容到文件。
+# - `find(directory, pattern)`: 在指定目录中查找匹配模式的文件。
+# - `deleteFile(path)`: 删除文件或目录。
+# - `Tools.Net`: 网络操作。
+# - `fetch(url, options)`: 发起HTTP请求,类似于浏览器中的`fetch`。
+# - `Tools.UI`: 与设备UI交互(高级功能)。
+# - `Tools.Memory`: 记忆管理功能。
+# - `Tools.FFmpeg`: 音视频处理(高级功能)。
+#
+# 所有的 `Tools` 函数都是【异步】的,意味着你必须使用 `await` 来调用它们,
+# 并且调用它们的代码必须位于 `async` 函数内部。
+#
+# 在上面的 `Part 5` 中,我们已经为你演示了如何实现一个名为 `system_inspector` 的新工具。
+# 它完美地展示了:
+# 1. 如何在核心逻辑函数中调用 `Tools.System.getDeviceInfo()` 和 `Tools.Files.find()`。
+# 2. 如何按顺序地调用多个异步工具,等待每一个操作完成后再进行下一步。
+# 3. 如何将这些调用集成到我们健壮的 `Wrapper` 模式中。
+
+
+# ==========================================
+# 最终导出 (Final Export)
+# ==========================================
+# 将我们在最终模式中定义的函数,挂载到exports上。
+# 这里的 `greet` 和 `system_inspector` 必须和METADATA中定义的工具 `name` 完全一致。
+exports.greet = greet_exported_function
+exports.system_inspector = inspect_system_exported_function
+
+# 恭喜! 你已经学完了从零到一的完整开发流程。
+#
+# 现在回顾一下我们构建最终代码形态的旅程:
+# 1. **基础语法**: 掌握了编写代码的基本工具。
+# 2. **基础工具+complete()**: 理解了AI工具的本质和与系统的交互方式。
+# 3. **Metadata+exports**: 学会了如何让AI"发现"并"使用"你的工具。
+# 4. **模块作用域**: 学会了如何通过作用域隔离来组织代码,避免命名冲突,使代码更模块化。
+# 5. **Wrapper模式**: 学会了如何通过分离"业务逻辑"和"模板代码"来编写更简洁、更健壮、更易于维护的工具代码。
+# 6. **内置工具 `Tools`**: 学会了如何利用系统提供的强大工具与文件、网络和操作系统进行交互。
+#
+# 这套"Wrapper + 模块作用域 + Tools"的组合是经过实践检验的最佳实践,强烈建议你在开发自己的工具时采用它。
diff --git a/operit_py/qwen_draw.py b/operit_py/qwen_draw.py
new file mode 100644
index 0000000..70a0cd6
--- /dev/null
+++ b/operit_py/qwen_draw.py
@@ -0,0 +1,372 @@
+# METADATA
+# {
+# "name": "qwen_draw",
+# "display_name": {
+# "zh": "Qwen 绘图",
+# "en": "Qwen Draw"
+# },
+# "description": {
+# "zh": "使用阿里云百炼/DashScope 文生图接口(通义万相/通义千问图像)根据提示词画图(异步任务轮询),将图片保存到本地 /sdcard/Download/Operit/plugins/draw/qwen_draw/draws/ 目录,并返回 Markdown 图片提示。",
+# "en": "Generate images via Alibaba Cloud Model Studio (DashScope) text-to-image API (async task polling), save to /sdcard/Download/Operit/plugins/draw/qwen_draw/draws/, and return a Markdown image reference."
+# },
+# "env": [
+# { "name": "DASHSCOPE_API_KEY", "description": { "zh": "DashScope API Key(必填)", "en": "DashScope API key (required)" }, "required": true },
+# { "name": "DASHSCOPE_API_BASE_URL", "description": { "zh": "DashScope API Base URL(可选,不填则默认 https://dashscope.aliyuncs.com;国际站可用 https://dashscope-intl.aliyuncs.com)", "en": "DashScope API base URL (optional; defaults to https://dashscope.aliyuncs.com; intl: https://dashscope-intl.aliyuncs.com)" }, "required": false },
+# { "name": "QWEN_IMAGE_MODEL", "description": { "zh": "默认文生图模型(可选;当 draw_image 未传 model 时使用,例如 qwen-image-plus 或 wan2.2-t2i-flash)", "en": "Default image model (optional; used when draw_image doesn't pass model), e.g. qwen-image-plus or wan2.2-t2i-flash" }, "required": false }
+# ],
+# "category": "Draw",
+# "tools": [
+# {
+# "name": "draw_image",
+# "description": { "zh": "根据提示词调用 DashScope 文生图接口生成图片,保存到本地并返回 Markdown 图片提示。", "en": "Generate an image via DashScope text-to-image API using a prompt, save it locally, and return a Markdown image reference." },
+# "parameters": [
+# { "name": "prompt", "description": { "zh": "绘图提示词(英文或中文皆可)", "en": "Prompt for image generation (Chinese or English)" }, "type": "string", "required": true },
+# { "name": "model", "description": { "zh": "模型名称(可选;不传则使用环境变量 QWEN_IMAGE_MODEL,再不行使用默认值)", "en": "Model name (optional; falls back to env QWEN_IMAGE_MODEL, then default)" }, "type": "string", "required": false },
+# { "name": "size", "description": { "zh": "输出图像分辨率,如 '1664*928' 或 '1024x1024'(可选)", "en": "Output image resolution, e.g. '1664*928' or '1024x1024' (optional)" }, "type": "string", "required": false },
+# { "name": "n", "description": { "zh": "生成图片数量(可选,默认 1)", "en": "Number of images (optional; default 1)" }, "type": "number", "required": false },
+# { "name": "negative_prompt", "description": { "zh": "负面提示词(可选)", "en": "Negative prompt (optional)" }, "type": "string", "required": false },
+# { "name": "prompt_extend", "description": { "zh": "是否开启 prompt 智能改写(可选,默认 true)", "en": "Enable prompt extension (optional; default true)" }, "type": "boolean", "required": false },
+# { "name": "watermark", "description": { "zh": "是否加水印(可选,默认 false)", "en": "Enable watermark (optional; default false)" }, "type": "boolean", "required": false },
+# { "name": "file_name", "description": { "zh": "自定义保存到本地的文件名(不含路径和扩展名)", "en": "Custom output file name (without path or extension)" }, "type": "string", "required": false },
+# { "name": "api_base_url", "description": { "zh": "DashScope API Base URL(不传则取环境变量 DASHSCOPE_API_BASE_URL 或默认 https://dashscope.aliyuncs.com )", "en": "DashScope API base URL (optional; falls back to env DASHSCOPE_API_BASE_URL or https://dashscope.aliyuncs.com)" }, "type": "string", "required": false },
+# { "name": "poll_interval_ms", "description": { "zh": "轮询间隔(毫秒),默认 2000", "en": "Polling interval (milliseconds), default 2000" }, "type": "number", "required": false },
+# { "name": "max_wait_time_ms", "description": { "zh": "最长等待时间(毫秒),默认 10 分钟", "en": "Max wait time (milliseconds), default 10 minutes" }, "type": "number", "required": false }
+# ]
+# }
+# ]
+# }
+
+import json
+import math
+import re
+import time
+
+HTTP_TIMEOUT_MS = 600000
+client = OkHttp.newBuilder().connectTimeout(HTTP_TIMEOUT_MS).readTimeout(HTTP_TIMEOUT_MS).writeTimeout(HTTP_TIMEOUT_MS).build()
+
+DEFAULT_API_BASE_URL = "https://dashscope.aliyuncs.com"
+DEFAULT_MODEL = "qwen-image-plus"
+
+DRAW_ROOT_DIR = getPluginConfigDir("draw")
+STORAGE_DIR = f"{DRAW_ROOT_DIR}/qwen_draw"
+DRAWS_DIR = f"{STORAGE_DIR}/draws"
+
+POLL_INTERVAL_MS = 2000
+MAX_WAIT_TIME_MS = 600000
+
+
+def _is_finite(n):
+ return isinstance(n, (int, float)) and not isinstance(n, bool) and math.isfinite(n)
+
+
+def isRecord(value):
+ return isinstance(value, dict)
+
+
+def getErrorMessage(error):
+ if isinstance(error, Exception):
+ return str(error)
+ return str(error)
+
+
+def getErrorStack(error):
+ if isinstance(error, Exception):
+ import traceback
+ return traceback.format_exc()
+ return None
+
+
+def normalizePositiveInt(value, fallback):
+ if value is None:
+ return fallback
+ n = value if isinstance(value, (int, float)) else int(str(value), 10)
+ if not _is_finite(n) or n <= 0:
+ return fallback
+ return int(n)
+
+
+def joinUrl(baseUrl, path):
+ normalizedBase = baseUrl if baseUrl.endswith("/") else f"{baseUrl}/"
+ normalizedPath = path[1:] if path.startswith("/") else path
+ return f"{normalizedBase}{normalizedPath}"
+
+
+def getApiKey():
+ apiKey = getEnv("DASHSCOPE_API_KEY")
+ if not apiKey:
+ raise Exception("DASHSCOPE_API_KEY 未配置,请在环境变量中设置 DashScope 的 API Key。")
+ return apiKey
+
+
+def getApiBaseUrl(customBaseUrl=None):
+ fromParam = str(customBaseUrl or "").strip()
+ if fromParam:
+ return fromParam
+
+ fromEnv = str(getEnv("DASHSCOPE_API_BASE_URL") or "").strip()
+ if fromEnv:
+ return fromEnv
+
+ return DEFAULT_API_BASE_URL
+
+
+def getTextToImageEndpoint(baseUrl):
+ trimmed = baseUrl.strip()
+ if not trimmed:
+ return joinUrl(DEFAULT_API_BASE_URL, "api/v1/services/aigc/text2image/image-synthesis")
+
+ if "/api/v1/services/aigc/text2image/image-synthesis" in trimmed:
+ return trimmed
+
+ if trimmed.endswith("/api/v1"):
+ return joinUrl(trimmed, "services/aigc/text2image/image-synthesis")
+ if trimmed.endswith("/api/v1/"):
+ return joinUrl(trimmed, "services/aigc/text2image/image-synthesis")
+
+ return joinUrl(trimmed, "api/v1/services/aigc/text2image/image-synthesis")
+
+
+def getTaskEndpoint(baseUrl, taskId):
+ trimmed = baseUrl.strip()
+ safeBase = trimmed or DEFAULT_API_BASE_URL
+ if "/api/v1/tasks/" in safeBase:
+ idx = safeBase.index("/api/v1/tasks/")
+ return safeBase[:idx] + f"/api/v1/tasks/{taskId}"
+ if safeBase.endswith("/api/v1"):
+ return joinUrl(safeBase, f"tasks/{taskId}")
+ if safeBase.endswith("/api/v1/"):
+ return joinUrl(safeBase, f"tasks/{taskId}")
+ return joinUrl(safeBase, f"api/v1/tasks/{taskId}")
+
+
+def sanitizeFileName(name):
+ safe = re.sub(r'[\\/:*?"<>|]', "_", str(name)).strip()
+ if not safe:
+ return f"qwen_draw_{int(time.time() * 1000)}"
+ return safe[:80]
+
+
+def buildFileName(prompt, customName=None):
+ if customName and str(customName).strip():
+ return sanitizeFileName(customName)
+ shortPrompt = f"{prompt[:40]}..." if len(prompt) > 40 else prompt
+ base = sanitizeFileName(shortPrompt or "image")
+ timestamp = int(time.time() * 1000)
+ return f"{base}_{timestamp}"
+
+
+def normalizeSize(size=None):
+ raw = str(size or "").strip()
+ if not raw:
+ return None
+
+ cleaned = raw.replace("×", "*").replace("x", "*").replace("X", "*")
+ cleaned = re.sub(r"\s+", "", cleaned)
+ if not re.match(r"^\d+\*\d+$", cleaned):
+ return None
+ return cleaned
+
+
+def guessExtensionFromUrl(url):
+ match = re.search(r"\.(png|jpg|jpeg|webp|gif)(?:\?|#|$)", url, re.IGNORECASE)
+ if match and match.group(1):
+ return match.group(1).lower()
+ return "png"
+
+
+async def ensureDirectories():
+ dirs = [DRAW_ROOT_DIR, STORAGE_DIR, DRAWS_DIR]
+ for dir_ in dirs:
+ try:
+ result = await Tools.Files.mkdir(dir_)
+ if not result.get("successful"):
+ console.warn(f"创建目录失败(可能已存在): {dir_} -> {result.get('details')}")
+ except Exception as e:
+ console.warn(f"创建目录异常: {dir_} -> {getErrorMessage(e)}")
+
+
+async def createTask(params):
+ apiKey = getApiKey()
+ apiBaseUrl = getApiBaseUrl(params.get("api_base_url"))
+ endpoint = getTextToImageEndpoint(apiBaseUrl)
+
+ modelFromParam = str(params.get("model") or "").strip()
+ modelFromEnv = str(getEnv("QWEN_IMAGE_MODEL") or "").strip()
+ effectiveModel = modelFromParam or modelFromEnv or DEFAULT_MODEL
+
+ body = {
+ "model": effectiveModel,
+ "input": {
+ "prompt": params["prompt"]
+ },
+ "parameters": {
+ "n": int(params["n"]) if isinstance(params.get("n"), (int, float)) and _is_finite(params["n"]) and params["n"] > 0 else 1,
+ "prompt_extend": True if params.get("prompt_extend") is None else bool(params.get("prompt_extend")),
+ "watermark": False if params.get("watermark") is None else bool(params.get("watermark"))
+ }
+ }
+
+ normalizedSize = normalizeSize(params.get("size"))
+ if normalizedSize:
+ body["parameters"]["size"] = normalizedSize
+
+ negativePrompt = str(params.get("negative_prompt") or "").strip()
+ if negativePrompt:
+ body["parameters"]["negative_prompt"] = negativePrompt
+
+ headers = {
+ "accept": "application/json",
+ "content-type": "application/json",
+ "Authorization": f"Bearer {apiKey}",
+ "X-DashScope-Async": "enable"
+ }
+
+ request = client.newRequest().url(endpoint).method("POST").headers(headers).body(json.dumps(body), "json")
+
+ response = await request.build().execute()
+
+ if not response.isSuccessful():
+ raise Exception(f"DashScope 文生图创建任务失败: {response.statusCode} - {response.content}")
+
+ try:
+ parsed = json.loads(response.content)
+ except Exception as e:
+ raise Exception(f"解析 DashScope 创建任务响应失败: {getErrorMessage(e)}")
+
+ output = parsed.get("output") if isRecord(parsed) and isRecord(parsed.get("output")) else None
+ taskId = output.get("task_id") if (output and isinstance(output.get("task_id"), str)) else ""
+ if not taskId:
+ raise Exception(f"DashScope 创建任务响应中未找到 output.task_id: {response.content}")
+
+ return {"task_id": taskId, "effective_model": effectiveModel}
+
+
+async def pollTask(params):
+ apiKey = getApiKey()
+ apiBaseUrl = getApiBaseUrl(params.get("api_base_url"))
+
+ pollIntervalMs = normalizePositiveInt(params.get("poll_interval_ms"), POLL_INTERVAL_MS)
+ maxWaitTimeMs = normalizePositiveInt(params.get("max_wait_time_ms"), MAX_WAIT_TIME_MS)
+
+ startTime = int(time.time() * 1000)
+ attempts = 0
+
+ while int(time.time() * 1000) - startTime < maxWaitTimeMs:
+ attempts += 1
+
+ endpoint = getTaskEndpoint(apiBaseUrl, params["task_id"])
+ headers = {
+ "accept": "application/json",
+ "Authorization": f"Bearer {apiKey}"
+ }
+
+ request = client.newRequest().url(endpoint).method("GET").headers(headers)
+
+ response = await request.build().execute()
+
+ if not response.isSuccessful():
+ raise Exception(f"DashScope 查询任务失败: {response.statusCode} - {response.content}")
+
+ try:
+ parsed = json.loads(response.content)
+ except Exception as e:
+ raise Exception(f"解析 DashScope 查询任务响应失败: {getErrorMessage(e)}")
+
+ output = parsed.get("output") if isRecord(parsed) and isRecord(parsed.get("output")) else None
+ taskStatus = str(output.get("task_status")) if (output and isinstance(output.get("task_status"), str)) else ""
+
+ if taskStatus == "SUCCEEDED":
+ results = output.get("results") if output else None
+ first = results[0] if (isinstance(results, list) and len(results) > 0) else None
+ url = first.get("url") if isRecord(first) else None
+ if not ((isinstance(url, str) or isinstance(url, (int, float))) and str(url).strip()):
+ raise Exception(f"任务已完成但未找到图片 URL: {response.content}")
+ return {"image_url": str(url), "task_status": taskStatus}
+
+ if taskStatus == "FAILED":
+ raise Exception(f"任务失败: {response.content}")
+
+ if attempts % 5 == 0:
+ console.log(f"任务状态: {taskStatus or 'UNKNOWN'},继续等待...")
+
+ await Tools.System.sleep(pollIntervalMs)
+
+ raise Exception(f"任务超时: 等待超过{math.ceil(maxWaitTimeMs / 60000)}分钟仍未完成")
+
+
+async def draw_image(params):
+ if not params or not params.get("prompt") or not str(params["prompt"]).strip():
+ raise Exception("参数 prompt 不能为空。")
+
+ prompt = str(params["prompt"]).strip()
+
+ await ensureDirectories()
+
+ createResult = await createTask({
+ "prompt": prompt,
+ "model": params.get("model"),
+ "size": params.get("size"),
+ "n": params.get("n"),
+ "negative_prompt": params.get("negative_prompt"),
+ "prompt_extend": params.get("prompt_extend"),
+ "watermark": params.get("watermark"),
+ "api_base_url": params.get("api_base_url")
+ })
+
+ pollResult = await pollTask({
+ "task_id": createResult["task_id"],
+ "api_base_url": params.get("api_base_url"),
+ "poll_interval_ms": params.get("poll_interval_ms"),
+ "max_wait_time_ms": params.get("max_wait_time_ms")
+ })
+
+ ext = guessExtensionFromUrl(pollResult["image_url"])
+ baseName = buildFileName(prompt, params.get("file_name"))
+ filePath = f"{DRAWS_DIR}/{baseName}.{ext}"
+
+ downloadResult = await Tools.Files.download(pollResult["image_url"], filePath)
+ if not downloadResult.get("successful"):
+ raise Exception(f"下载图片失败: {downloadResult.get('details')}")
+
+ fileUri = f"file://{filePath}"
+ markdown = f""
+
+ hintLines = []
+ hintLines.append(f"图片已生成并保存在本地 {DRAWS_DIR}。")
+ hintLines.append(f"本地路径: {filePath}")
+ hintLines.append("")
+ hintLines.append("在后续回答中,请直接输出下面这一行 Markdown 来展示这张图片:")
+ hintLines.append("")
+ hintLines.append(markdown)
+
+ return {
+ "file_path": filePath,
+ "file_uri": fileUri,
+ "markdown": markdown,
+ "prompt": prompt,
+ "model": createResult["effective_model"],
+ "task_id": createResult["task_id"],
+ "task_status": pollResult["task_status"],
+ "image_url": pollResult["image_url"],
+ "hint": "\n".join(hintLines)
+ }
+
+
+async def draw_image_wrapper(params):
+ try:
+ result = await draw_image(params)
+ complete({
+ "success": True,
+ "message": f"图片生成成功,已保存到 {DRAWS_DIR},并返回 Markdown 图片提示。",
+ "data": result
+ })
+ except Exception as error:
+ console.error("draw_image 执行失败:", error)
+ complete({
+ "success": False,
+ "message": f"图片生成失败: {getErrorMessage(error)}",
+ "error_stack": getErrorStack(error)
+ })
+
+
+exports.draw_image = draw_image_wrapper
diff --git a/operit_py/reader.py b/operit_py/reader.py
new file mode 100644
index 0000000..cc0bcd1
--- /dev/null
+++ b/operit_py/reader.py
@@ -0,0 +1,485 @@
+# METADATA
+# {
+# "name": "code_reader",
+# "display_name": {"zh": "代码读取器", "en": "Code Reader"},
+# "description": {"zh": "提供代码匹配抓取功能", "en": "Provide code searching and extraction utilities."},
+# "enabledByDefault": True,
+# "category": "File",
+# "tools": [
+# {"name": "search_code_in_folder", "description": {"zh": "在文件夹中搜索匹配的代码", "en": "Search for matching code in a folder."}, "parameters": [
+# {"name": "folder_path", "description": {"zh": "要搜索的文件夹路径", "en": "Folder path to search."}, "type": "string", "required": True},
+# {"name": "pattern", "description": {"zh": "要搜索的代码模式(字符串或正则表达式)", "en": "Pattern to search for (string or regular expression)."}, "type": "string", "required": True},
+# {"name": "file_extensions", "description": {"zh": "要搜索的文件扩展名列表(如 \".ts,.js\")", "en": "File extension list to include (e.g. \".ts,.js\")."}, "type": "string", "required": False},
+# {"name": "recursive", "description": {"zh": "是否递归搜索子文件夹(true/false)", "en": "Whether to search subfolders recursively (true/false)."}, "type": "string", "required": False}
+# ]},
+# {"name": "extract_functions", "description": {"zh": "提取文件夹中的所有函数定义", "en": "Extract all function definitions from a folder."}, "parameters": [
+# {"name": "folder_path", "description": {"zh": "要搜索的文件夹路径", "en": "Folder path to search."}, "type": "string", "required": True},
+# {"name": "function_name_pattern", "description": {"zh": "函数名匹配模式(可选)", "en": "Function name matching pattern (optional)."}, "type": "string", "required": False},
+# {"name": "file_extensions", "description": {"zh": "要搜索的文件扩展名列表(如 \".ts,.js\")", "en": "File extension list to include (e.g. \".ts,.js\")."}, "type": "string", "required": False},
+# {"name": "recursive", "description": {"zh": "是否递归搜索子文件夹(true/false)", "en": "Whether to search subfolders recursively (true/false)."}, "type": "string", "required": False}
+# ]},
+# {"name": "extract_function_block", "description": {"zh": "提取特定函数的代码块", "en": "Extract the code block for a specific function."}, "parameters": [
+# {"name": "file_path", "description": {"zh": "要读取的文件路径", "en": "File path to read."}, "type": "string", "required": True},
+# {"name": "function_name", "description": {"zh": "要提取的函数名称", "en": "Function name to extract."}, "type": "string", "required": True},
+# {"name": "include_line_numbers", "description": {"zh": "是否在结果中包含行号(true/false)", "en": "Whether to include line numbers in the result (true/false)."}, "type": "string", "required": False}
+# ]},
+# {"name": "find_code_blocks", "description": {"zh": "查找文件中的代码块", "en": "Find code blocks in a file."}, "parameters": [
+# {"name": "file_path", "description": {"zh": "要读取的文件路径", "en": "File path to read."}, "type": "string", "required": True},
+# {"name": "block_pattern", "description": {"zh": "代码块匹配模式(如类定义、接口等)", "en": "Code block matching pattern (e.g., class definition, interface, etc.)."}, "type": "string", "required": True},
+# {"name": "include_line_numbers", "description": {"zh": "是否在结果中包含行号(true/false)", "en": "Whether to include line numbers in the result (true/false)."}, "type": "string", "required": False}
+# ]},
+# {"name": "read_regex_matches", "description": {"zh": "读取文件中匹配正则表达式的内容", "en": "Read content in a file that matches a regular expression."}, "parameters": [
+# {"name": "file_path", "description": {"zh": "要读取的文件路径", "en": "File path to read."}, "type": "string", "required": True},
+# {"name": "regex", "description": {"zh": "正则表达式字符串", "en": "Regular expression string."}, "type": "string", "required": True},
+# {"name": "include_line_numbers", "description": {"zh": "是否在结果中包含行号(true/false)", "en": "Whether to include line numbers in the result (true/false)."}, "type": "string", "required": False}
+# ]}
+# ]
+# }
+
+import json
+import re
+import time
+import math
+from urllib.parse import quote, unquote
+
+Error = Exception
+
+
+def convertParamTypes(params, paramTypes):
+ if not params or not paramTypes:
+ return params
+
+ result = {}
+
+ for key in params:
+ if params[key] is None:
+ result[key] = params[key]
+ continue
+
+ expectedType = paramTypes.get(key)
+ if not expectedType:
+ result[key] = params[key]
+ continue
+
+ value = params[key]
+
+ try:
+ et = expectedType.lower()
+ if et == 'number':
+ if isinstance(value, str):
+ if '.' in value:
+ result[key] = float(value)
+ else:
+ result[key] = int(value, 10)
+ if isinstance(result[key], float) and math.isnan(result[key]):
+ raise Error(f"参数 {key} 无法转换为数字: {value}")
+ else:
+ result[key] = value
+ elif et == 'boolean':
+ if isinstance(value, str):
+ lowerValue = value.lower()
+ if lowerValue in ('true', '1', 'yes'):
+ result[key] = True
+ elif lowerValue in ('false', '0', 'no'):
+ result[key] = False
+ else:
+ raise Error(f"参数 {key} 无法转换为布尔值: {value}")
+ else:
+ result[key] = value
+ elif et == 'array':
+ if isinstance(value, str):
+ try:
+ result[key] = json.loads(value)
+ if not isinstance(result[key], list):
+ raise Error('解析结果不是数组')
+ except Exception:
+ raise Error(f"参数 {key} 无法转换为数组: {value}")
+ else:
+ result[key] = value
+ elif et == 'object':
+ if isinstance(value, str):
+ try:
+ result[key] = json.loads(value)
+ if isinstance(result[key], list) or not isinstance(result[key], dict):
+ raise Error('解析结果不是对象')
+ except Exception:
+ raise Error(f"参数 {key} 无法转换为对象: {value}")
+ else:
+ result[key] = value
+ else:
+ result[key] = value
+ except Exception as error:
+ console.error(f"参数类型转换错误: {str(error)}")
+ result[key] = value
+
+ return result
+
+
+async def writer_wrap(func, params, successMessage, failMessage):
+ try:
+ console.log(f"开始执行函数: {func.__name__ or '匿名函数'}")
+ console.log("参数:", json.dumps(params, indent=2, ensure_ascii=False))
+
+ result = await func(params)
+
+ console.log(f"函数 {func.__name__ or '匿名函数'} 执行结果:", json.dumps(result, indent=2, ensure_ascii=False, default=str))
+
+ if result is None:
+ return
+
+ if isinstance(result, bool):
+ complete({
+ "success": result,
+ "message": successMessage if result else failMessage,
+ })
+ else:
+ complete({
+ "success": True,
+ "message": successMessage,
+ "data": result,
+ })
+ except Exception as error:
+ console.error(f"函数 {func.__name__ or '匿名函数'} 执行失败!")
+ console.error(f"错误信息: {str(error)}")
+ import traceback
+ stack = traceback.format_exc()
+ console.error(f"错误堆栈: {stack}")
+
+ complete({
+ "success": False,
+ "message": f"{failMessage}: {str(error)}",
+ "error_stack": stack,
+ })
+
+
+async def readFilesRecursively(dir_, fileList=None, extensions=None):
+ if fileList is None:
+ fileList = []
+ try:
+ listResult = await Tools.Files.list(dir_)
+ entries = getattr(listResult, "entries", None) or []
+
+ for entry in entries:
+ filePath = f"{dir_}/{entry.name}"
+
+ if getattr(entry, "isDirectory", False):
+ await readFilesRecursively(filePath, fileList, extensions)
+ else:
+ if not extensions or len(extensions) == 0 or any(entry.name.lower().endswith(ext.lower()) for ext in extensions):
+ fileList.append(filePath)
+ except Exception as error:
+ console.error(f"读取目录失败: {str(error)}")
+
+ return fileList
+
+
+async def search_code_in_folder(params):
+ paramTypes = {"recursive": "boolean"}
+ params = convertParamTypes(params, paramTypes)
+
+ folder_path = params.get("folder_path")
+ pattern = params.get("pattern")
+ file_extensions = params.get("file_extensions", ".js,.ts,.jsx,.tsx,.java,.cs,.py")
+ recursive = params.get("recursive", True)
+
+ if not folder_path:
+ raise Error("文件夹路径不能为空")
+ if not pattern:
+ raise Error("搜索模式不能为空")
+
+ try:
+ patternRegex = re.compile(pattern, re.MULTILINE)
+ extensions = file_extensions.split(",")
+
+ if recursive:
+ files = await readFilesRecursively(folder_path, [], extensions)
+ else:
+ listResult = await Tools.Files.list(folder_path)
+ entries = getattr(listResult, "entries", None) or []
+ files = [
+ f"{folder_path}/{entry.name}"
+ for entry in entries
+ if not getattr(entry, "isDirectory", False)
+ and any(entry.name.lower().endswith(ext.lower()) for ext in extensions)
+ ]
+
+ results = []
+
+ for file in files:
+ try:
+ fileResult = await Tools.Files.read(file)
+ content = getattr(fileResult, "content", "") or ""
+ matches = []
+
+ for match in patternRegex.finditer(content):
+ lineNumber = content[:match.start()].count("\n") + 1
+ lines = content.split("\n")
+ lineContent = lines[lineNumber - 1] if lineNumber - 1 < len(lines) else ""
+
+ matches.append({
+ "match": match.group(0),
+ "line": lineNumber,
+ "content": lineContent,
+ })
+
+ if len(matches) > 0:
+ results.append({
+ "file": file,
+ "matches": matches,
+ })
+ except Exception as error:
+ console.error(f"处理文件 {file} 时出错: {str(error)}")
+
+ return results
+ except Exception as error:
+ raise Error(f"搜索代码时出错: {str(error)}")
+
+
+async def extract_functions(params):
+ paramTypes = {"recursive": "boolean"}
+ params = convertParamTypes(params, paramTypes)
+
+ folder_path = params.get("folder_path")
+ function_name_pattern = params.get("function_name_pattern", ".*")
+ file_extensions = params.get("file_extensions", ".js,.ts,.jsx,.tsx")
+ recursive = params.get("recursive", True)
+
+ if not folder_path:
+ raise Error("文件夹路径不能为空")
+
+ try:
+ functionPatterns = [
+ f"function\\s+({function_name_pattern})\\s*\\([^)]*\\)\\s*\\{{",
+ f"(?:const|let|var)\\s+({function_name_pattern})\\s*=\\s*function\\s*\\([^)]*\\)\\s*\\{{",
+ f"(?:const|let|var)\\s+({function_name_pattern})\\s*=\\s*\\([^)]*\\)\\s*=>\\s*\\{{",
+ f"(?:async\\s+)?({function_name_pattern})\\s*\\([^)]*\\)\\s*\\{{",
+ ]
+
+ combinedPattern = "|".join(functionPatterns)
+
+ return await search_code_in_folder({
+ "folder_path": folder_path,
+ "pattern": combinedPattern,
+ "file_extensions": file_extensions,
+ "recursive": recursive,
+ })
+ except Exception as error:
+ raise Error(f"提取函数时出错: {str(error)}")
+
+
+async def extract_function_block(params):
+ paramTypes = {"include_line_numbers": "boolean"}
+ params = convertParamTypes(params, paramTypes)
+
+ file_path = params.get("file_path")
+ function_name = params.get("function_name")
+ include_line_numbers = params.get("include_line_numbers", False)
+
+ if not file_path:
+ raise Error("文件路径不能为空")
+ if not function_name:
+ raise Error("函数名称不能为空")
+
+ try:
+ fileResult = await Tools.Files.read(file_path)
+ content = getattr(fileResult, "content", "") or ""
+ lines = content.split("\n")
+
+ functionPatterns = [
+ f"function\\s+{function_name}\\s*\\([^)]*\\)\\s*\\{{",
+ f"(?:const|let|var)\\s+{function_name}\\s*=\\s*function\\s*\\([^)]*\\)\\s*\\{{",
+ f"(?:const|let|var)\\s+{function_name}\\s*=\\s*\\([^)]*\\)\\s*=>\\s*\\{{",
+ f"(?:async\\s+)?{function_name}\\s*\\([^)]*\\)\\s*\\{{",
+ ]
+
+ combinedPattern = "|".join(functionPatterns)
+ functionRegex = re.compile(combinedPattern)
+
+ startLine = -1
+ for i in range(len(lines)):
+ if functionRegex.search(lines[i]):
+ startLine = i
+ break
+
+ if startLine == -1:
+ return {"error": f"未找到函数: {function_name}"}
+
+ bracesCount = 0
+ endLine = startLine
+
+ openingBraces = len(re.findall(r"{", lines[startLine]))
+ closingBraces = len(re.findall(r"}", lines[startLine]))
+ bracesCount = openingBraces - closingBraces
+
+ for i in range(startLine + 1, len(lines)):
+ openBraces = len(re.findall(r"{", lines[i]))
+ closeBraces = len(re.findall(r"}", lines[i]))
+
+ bracesCount += openBraces
+ bracesCount -= closeBraces
+
+ if bracesCount == 0:
+ endLine = i
+ break
+
+ functionBlock = "\n".join(lines[startLine:endLine + 1])
+
+ result = {
+ "function_name": function_name,
+ "code": functionBlock,
+ }
+
+ if include_line_numbers:
+ result["start_line"] = startLine + 1
+ result["end_line"] = endLine + 1
+
+ return result
+ except Exception as error:
+ raise Error(f"提取函数代码块时出错: {str(error)}")
+
+
+async def find_code_blocks(params):
+ paramTypes = {"include_line_numbers": "boolean"}
+ params = convertParamTypes(params, paramTypes)
+
+ file_path = params.get("file_path")
+ block_pattern = params.get("block_pattern")
+ include_line_numbers = params.get("include_line_numbers", False)
+
+ if not file_path:
+ raise Error("文件路径不能为空")
+ if not block_pattern:
+ raise Error("代码块匹配模式不能为空")
+
+ try:
+ fileResult = await Tools.Files.read(file_path)
+ content = getattr(fileResult, "content", "") or ""
+ blockRegex = re.compile(block_pattern, re.MULTILINE)
+
+ matches = []
+
+ for match in blockRegex.finditer(content):
+ matchText = match.group(0)
+ startIndex = match.start()
+
+ lines_before = content[:startIndex].split("\n")
+ startLine = len(lines_before)
+
+ endIndex = startIndex + len(matchText)
+ bracesCount = 0
+
+ if "{" in matchText:
+ initialOpenBraces = len(re.findall(r"{", matchText))
+ initialCloseBraces = len(re.findall(r"}", matchText))
+ bracesCount = initialOpenBraces - initialCloseBraces
+
+ if bracesCount > 0:
+ remainingContent = content[endIndex:]
+ currentPos = 0
+
+ for i in range(len(remainingContent)):
+ char = remainingContent[i]
+
+ if char == "{":
+ bracesCount += 1
+ elif char == "}":
+ bracesCount -= 1
+
+ if bracesCount == 0:
+ currentPos = i + 1
+ break
+
+ endIndex += currentPos
+
+ codeBlock = content[startIndex:endIndex]
+
+ blockLines = codeBlock.split("\n")
+ endLine = startLine + len(blockLines) - 1
+
+ result = {"block": codeBlock}
+
+ if include_line_numbers:
+ result["start_line"] = startLine
+ result["end_line"] = endLine
+
+ matches.append(result)
+
+ return {
+ "file": file_path,
+ "blocks": matches,
+ }
+ except Exception as error:
+ raise Error(f"查找代码块时出错: {str(error)}")
+
+
+async def read_regex_matches(params):
+ paramTypes = {"include_line_numbers": "boolean"}
+ params = convertParamTypes(params, paramTypes)
+
+ file_path = params.get("file_path")
+ regex = params.get("regex")
+ include_line_numbers = params.get("include_line_numbers", False)
+
+ if not file_path:
+ raise Error("文件路径不能为空")
+ if not regex:
+ raise Error("正则表达式不能为空")
+
+ try:
+ fileResult = await Tools.Files.read(file_path)
+ content = getattr(fileResult, "content", "") or ""
+ lines = content.split("\n")
+ pattern = re.compile(regex, re.MULTILINE)
+
+ matches = []
+
+ for match in pattern.finditer(content):
+ lineNumber = content[:match.start()].count("\n") + 1
+ lineContent = lines[lineNumber - 1] if lineNumber - 1 < len(lines) else ""
+
+ result = {
+ "match": match.group(0),
+ "content": lineContent,
+ }
+
+ if include_line_numbers:
+ result["line"] = lineNumber
+
+ if match.lastindex and match.lastindex >= 1:
+ result["groups"] = [match.group(i) for i in range(1, match.lastindex + 1)]
+
+ matches.append(result)
+
+ return {
+ "file": file_path,
+ "matches": matches,
+ }
+ except Exception as error:
+ raise Error(f"匹配正则表达式时出错: {str(error)}")
+
+
+async def _search_code_in_folder_wrapper(params):
+ await writer_wrap(search_code_in_folder, params, "代码搜索完成", "代码搜索失败")
+
+
+async def _extract_functions_wrapper(params):
+ await writer_wrap(extract_functions, params, "函数提取完成", "函数提取失败")
+
+
+async def _extract_function_block_wrapper(params):
+ await writer_wrap(extract_function_block, params, "函数代码块提取完成", "函数代码块提取失败")
+
+
+async def _find_code_blocks_wrapper(params):
+ await writer_wrap(find_code_blocks, params, "代码块查找完成", "代码块查找失败")
+
+
+async def _read_regex_matches_wrapper(params):
+ await writer_wrap(read_regex_matches, params, "正则表达式匹配完成", "正则表达式匹配失败")
+
+
+exports.search_code_in_folder = _search_code_in_folder_wrapper
+exports.extract_functions = _extract_functions_wrapper
+exports.extract_function_block = _extract_function_block_wrapper
+exports.find_code_blocks = _find_code_blocks_wrapper
+exports.read_regex_matches = _read_regex_matches_wrapper
diff --git a/operit_py/siliconflow_draw.py b/operit_py/siliconflow_draw.py
new file mode 100644
index 0000000..225b72d
--- /dev/null
+++ b/operit_py/siliconflow_draw.py
@@ -0,0 +1,584 @@
+# METADATA
+# {
+# "name": "siliconflow_draw",
+# "display_name": {
+# "zh": "硅基流动绘图",
+# "en": "SiliconFlow Draw"
+# },
+# "description": {
+# "zh": "使用 SiliconFlow 官方图像与视频接口生成图片和视频。图片走 /v1/images/generations,视频走 /v1/video/submit + /v1/video/status;生成结果会立即下载到本地,避免官方临时链接过期。",
+# "en": "Generate images and videos with SiliconFlow official image and video APIs. Images use /v1/images/generations; videos use /v1/video/submit + /v1/video/status. Generated assets are downloaded locally immediately before temporary URLs expire."
+# },
+# "category": "Draw",
+# "env": [
+# { "name": "SILICONFLOW_API_KEY", "description": { "zh": "SiliconFlow API Key(必填)", "en": "SiliconFlow API key (required)" }, "required": true },
+# { "name": "SILICONFLOW_API_BASE_URL", "description": { "zh": "SiliconFlow API Base URL(可选,不填默认 https://api.siliconflow.cn )", "en": "SiliconFlow API base URL (optional; defaults to https://api.siliconflow.cn)" }, "required": false },
+# { "name": "SILICONFLOW_IMAGE_MODEL", "description": { "zh": "默认生图模型(可选;例如 Kwai-Kolors/Kolors、Qwen/Qwen-Image)", "en": "Default image model (optional), e.g. Kwai-Kolors/Kolors or Qwen/Qwen-Image" }, "required": false },
+# { "name": "SILICONFLOW_VIDEO_MODEL", "description": { "zh": "默认生视频模型(可选;例如 Wan-AI/Wan2.2-T2V-A14B 或 Wan-AI/Wan2.2-I2V-A14B)", "en": "Default video model (optional), e.g. Wan-AI/Wan2.2-T2V-A14B or Wan-AI/Wan2.2-I2V-A14B" }, "required": false }
+# ],
+# "tools": [
+# {
+# "name": "draw_image",
+# "description": { "zh": "调用 SiliconFlow 官方图片生成接口生图,下载到本地并返回 Markdown 图片提示。", "en": "Generate images with SiliconFlow official image API, download locally, and return Markdown image hints." },
+# "parameters": [
+# { "name": "prompt", "description": { "zh": "生图提示词", "en": "Image prompt" }, "type": "string", "required": true },
+# { "name": "model", "description": { "zh": "模型名;不传则优先取 SILICONFLOW_IMAGE_MODEL,再取默认值 Kwai-Kolors/Kolors", "en": "Model name; falls back to SILICONFLOW_IMAGE_MODEL, then Kwai-Kolors/Kolors" }, "type": "string", "required": false },
+# { "name": "image_size", "description": { "zh": "图片尺寸,例如 1024x1024;未传时默认 1024x1024", "en": "Image size, e.g. 1024x1024; defaults to 1024x1024" }, "type": "string", "required": false },
+# { "name": "negative_prompt", "description": { "zh": "负面提示词(可选)", "en": "Negative prompt (optional)" }, "type": "string", "required": false },
+# { "name": "batch_size", "description": { "zh": "一次生成多少张图(可选)", "en": "Number of images to generate (optional)" }, "type": "number", "required": false },
+# { "name": "num_inference_steps", "description": { "zh": "推理步数(可选)", "en": "Inference steps (optional)" }, "type": "number", "required": false },
+# { "name": "guidance_scale", "description": { "zh": "提示词引导强度(可选)", "en": "Guidance scale (optional)" }, "type": "number", "required": false },
+# { "name": "seed", "description": { "zh": "随机种子(可选)", "en": "Seed (optional)" }, "type": "number", "required": false },
+# { "name": "file_name", "description": { "zh": "本地保存文件名(不含扩展名)", "en": "Local output filename without extension" }, "type": "string", "required": false },
+# { "name": "api_base_url", "description": { "zh": "自定义 API Base URL(可选)", "en": "Custom API base URL (optional)" }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "draw_video",
+# "description": { "zh": "调用 SiliconFlow 官方视频生成接口生视频,轮询状态,下载到本地并返回本地视频链接提示。", "en": "Generate videos with SiliconFlow official video API, poll status, download locally, and return local video link hints." },
+# "parameters": [
+# { "name": "prompt", "description": { "zh": "生视频提示词", "en": "Video prompt" }, "type": "string", "required": true },
+# { "name": "model", "description": { "zh": "模型名;不传则优先取 SILICONFLOW_VIDEO_MODEL,若传了 image_url/image_path 默认走 Wan-AI/Wan2.2-I2V-A14B,否则默认 Wan-AI/Wan2.2-T2V-A14B", "en": "Model name; falls back to SILICONFLOW_VIDEO_MODEL. If image_url/image_path is provided, defaults to Wan-AI/Wan2.2-I2V-A14B; otherwise Wan-AI/Wan2.2-T2V-A14B" }, "type": "string", "required": false },
+# { "name": "image_size", "description": { "zh": "视频尺寸,可选 1280x720、720x1280、960x960;默认 1280x720", "en": "Video size: 1280x720, 720x1280, or 960x960; defaults to 1280x720" }, "type": "string", "required": false },
+# { "name": "negative_prompt", "description": { "zh": "负面提示词(可选)", "en": "Negative prompt (optional)" }, "type": "string", "required": false },
+# { "name": "image_url", "description": { "zh": "图生视频输入图 URL(可选)", "en": "Input image URL for image-to-video (optional)" }, "type": "string", "required": false },
+# { "name": "image_path", "description": { "zh": "图生视频输入图本地路径(可选,会转成 data URL 上传)", "en": "Local input image path for image-to-video (optional; converted to data URL)" }, "type": "string", "required": false },
+# { "name": "seed", "description": { "zh": "随机种子(可选)", "en": "Seed (optional)" }, "type": "number", "required": false },
+# { "name": "file_name", "description": { "zh": "本地保存文件名(不含扩展名)", "en": "Local output filename without extension" }, "type": "string", "required": false },
+# { "name": "api_base_url", "description": { "zh": "自定义 API Base URL(可选)", "en": "Custom API base URL (optional)" }, "type": "string", "required": false },
+# { "name": "poll_interval_ms", "description": { "zh": "轮询间隔毫秒数,默认 5000", "en": "Polling interval in ms, default 5000" }, "type": "number", "required": false },
+# { "name": "max_wait_time_ms", "description": { "zh": "最大等待毫秒数,默认 600000", "en": "Maximum wait time in ms, default 600000" }, "type": "number", "required": false }
+# ]
+# }
+# ]
+# }
+
+import json
+import math
+import re
+import time
+import traceback
+
+HTTP_TIMEOUT_MS = 600000
+client = OkHttp.newBuilder().connectTimeout(HTTP_TIMEOUT_MS).readTimeout(HTTP_TIMEOUT_MS).writeTimeout(HTTP_TIMEOUT_MS).build()
+
+DEFAULT_API_BASE_URL = "https://api.siliconflow.cn"
+DEFAULT_IMAGE_MODEL = "Kwai-Kolors/Kolors"
+DEFAULT_IMAGE_SIZE = "1024x1024"
+DEFAULT_VIDEO_TEXT_MODEL = "Wan-AI/Wan2.2-T2V-A14B"
+DEFAULT_VIDEO_IMAGE_MODEL = "Wan-AI/Wan2.2-I2V-A14B"
+DEFAULT_VIDEO_SIZE = "1280x720"
+DEFAULT_POLL_INTERVAL_MS = 5000
+DEFAULT_MAX_WAIT_TIME_MS = 600000
+VIDEO_SIZE_OPTIONS = ["1280x720", "720x1280", "960x960"]
+
+DRAW_ROOT_DIR = getPluginConfigDir("draw")
+STORAGE_DIR = f"{DRAW_ROOT_DIR}/siliconflow_draw"
+DRAWS_DIR = f"{STORAGE_DIR}/draws"
+VIDEOS_DIR = f"{STORAGE_DIR}/videos"
+
+
+def _is_finite(n):
+ return isinstance(n, (int, float)) and not isinstance(n, bool) and math.isfinite(n)
+
+
+def getApiKey():
+ apiKey = getEnv("SILICONFLOW_API_KEY")
+ if not apiKey:
+ raise Exception("SILICONFLOW_API_KEY 未配置,请先在环境变量中设置硅基流动 API Key。")
+ return apiKey
+
+
+def getApiBaseUrl(customBaseUrl=None):
+ fromParam = str(customBaseUrl or "").strip()
+ if fromParam:
+ return fromParam
+
+ fromEnv = str(getEnv("SILICONFLOW_API_BASE_URL") or "").strip()
+ if fromEnv:
+ return fromEnv
+
+ return DEFAULT_API_BASE_URL
+
+
+def joinUrl(baseUrl, path):
+ normalizedBase = baseUrl if baseUrl.endswith("/") else f"{baseUrl}/"
+ normalizedPath = path[1:] if path.startswith("/") else path
+ return f"{normalizedBase}{normalizedPath}"
+
+
+def getImageEndpoint(baseUrl):
+ trimmed = str(baseUrl or "").strip()
+ if not trimmed:
+ return joinUrl(DEFAULT_API_BASE_URL, "v1/images/generations")
+ if "/v1/images/generations" in trimmed:
+ return trimmed
+ if trimmed.endswith("/v1"):
+ return joinUrl(trimmed, "images/generations")
+ if trimmed.endswith("/v1/"):
+ return joinUrl(trimmed, "images/generations")
+ return joinUrl(trimmed, "v1/images/generations")
+
+
+def getVideoSubmitEndpoint(baseUrl):
+ trimmed = str(baseUrl or "").strip()
+ if not trimmed:
+ return joinUrl(DEFAULT_API_BASE_URL, "v1/video/submit")
+ if "/v1/video/submit" in trimmed:
+ return trimmed
+ if trimmed.endswith("/v1"):
+ return joinUrl(trimmed, "video/submit")
+ if trimmed.endswith("/v1/"):
+ return joinUrl(trimmed, "video/submit")
+ return joinUrl(trimmed, "v1/video/submit")
+
+
+def getVideoStatusEndpoint(baseUrl):
+ trimmed = str(baseUrl or "").strip()
+ if not trimmed:
+ return joinUrl(DEFAULT_API_BASE_URL, "v1/video/status")
+ if "/v1/video/status" in trimmed:
+ return trimmed
+ if trimmed.endswith("/v1"):
+ return joinUrl(trimmed, "video/status")
+ if trimmed.endswith("/v1/"):
+ return joinUrl(trimmed, "video/status")
+ return joinUrl(trimmed, "v1/video/status")
+
+
+def sanitizeFileName(name, prefix):
+ safe = re.sub(r'[\\/:*?"<>|]', "_", str(name or "")).strip()
+ if not safe:
+ return f"{prefix}_{int(time.time() * 1000)}"
+ return safe[:80]
+
+
+def buildFileBaseName(prompt, customName, prefix):
+ if customName and len(str(customName).strip()) > 0:
+ return sanitizeFileName(customName, prefix)
+ rawPrompt = str(prompt or "").strip()
+ shortPrompt = f"{rawPrompt[:40]}..." if len(rawPrompt) > 40 else rawPrompt
+ return f"{sanitizeFileName(shortPrompt or prefix, prefix)}_{int(time.time() * 1000)}"
+
+
+def getErrorMessage(error):
+ if isinstance(error, Exception):
+ return str(error)
+ return str(error)
+
+
+def guessMimeTypeFromPath(filePath):
+ lower = str(filePath or "").lower()
+ if lower.endswith(".png"):
+ return "image/png"
+ if lower.endswith(".jpg") or lower.endswith(".jpeg"):
+ return "image/jpeg"
+ if lower.endswith(".webp"):
+ return "image/webp"
+ if lower.endswith(".gif"):
+ return "image/gif"
+ return "image/png"
+
+
+def guessExtensionFromUrl(url, fallbackExtension):
+ match = re.search(r"\.([a-z0-9]{2,5})(?:[?#].*)?$", str(url or ""), re.IGNORECASE)
+ if match and match.group(1):
+ return match.group(1).lower()
+ return fallbackExtension
+
+
+def parsePositiveInteger(value, fieldName):
+ if value is None or value == "":
+ return None
+ try:
+ numberValue = float(value)
+ except (TypeError, ValueError):
+ raise Exception(f"{fieldName} 必须是正整数。")
+ if not _is_finite(numberValue) or numberValue <= 0:
+ raise Exception(f"{fieldName} 必须是正整数。")
+ return int(numberValue)
+
+
+def parseFiniteNumber(value, fieldName):
+ if value is None or value == "":
+ return None
+ try:
+ numberValue = float(value)
+ except (TypeError, ValueError):
+ raise Exception(f"{fieldName} 必须是有效数字。")
+ if not _is_finite(numberValue):
+ raise Exception(f"{fieldName} 必须是有效数字。")
+ return numberValue
+
+
+def parseSeed(value):
+ if value is None or value == "":
+ return None
+ try:
+ numberValue = float(value)
+ except (TypeError, ValueError):
+ raise Exception("seed 必须是有效数字。")
+ if not _is_finite(numberValue):
+ raise Exception("seed 必须是有效数字。")
+ return int(numberValue)
+
+
+def normalizeImageSize(imageSize, model):
+ trimmedModel = str(model or "").strip().lower()
+ rawSize = str(imageSize or "").strip()
+ isQwenEditModel = trimmedModel.startswith("qwen/qwen-image-edit")
+ if isQwenEditModel:
+ if rawSize:
+ raise Exception("当前模型为 Qwen 图像编辑模型,官方文档标注不支持 image_size 参数,请去掉 image_size。")
+ return None
+ if not rawSize:
+ return DEFAULT_IMAGE_SIZE
+ if not re.match(r"^\d+x\d+$", rawSize, re.IGNORECASE):
+ raise Exception("image_size 格式必须是 widthxheight,例如 1024x1024。")
+ return rawSize.lower()
+
+
+def normalizeVideoSize(imageSize):
+ rawSize = str(imageSize or "").strip()
+ if not rawSize:
+ return DEFAULT_VIDEO_SIZE
+ if rawSize not in VIDEO_SIZE_OPTIONS:
+ raise Exception(f"image_size 仅支持 {'、'.join(VIDEO_SIZE_OPTIONS)}。")
+ return rawSize
+
+
+def isProbablyUrl(value):
+ return bool(re.match(r"^https?://", str(value or "").strip(), re.IGNORECASE))
+
+
+async def ensureDirectories():
+ dirs = [DRAW_ROOT_DIR, STORAGE_DIR, DRAWS_DIR, VIDEOS_DIR]
+ for dir_ in dirs:
+ try:
+ result = await Tools.Files.mkdir(dir_)
+ if not result.get("successful"):
+ console.warn(f"创建目录失败(可能已存在): {dir_} -> {result.get('details')}")
+ except Exception as error:
+ console.warn(f"创建目录异常: {dir_} -> {getErrorMessage(error)}")
+
+
+async def readLocalImageAsDataUrl(filePath):
+ trimmedPath = str(filePath or "").strip()
+ if not trimmedPath:
+ raise Exception("image_path 不能为空。")
+ existsResult = await Tools.Files.exists(trimmedPath)
+ if not existsResult.get("exists"):
+ raise Exception(f"本地图片不存在: {trimmedPath}")
+ binaryResult = await Tools.Files.readBinary(trimmedPath)
+ base64Content = str(binaryResult.get("contentBase64") if binaryResult and binaryResult.get("contentBase64") else "").strip() if binaryResult else ""
+ if not base64Content:
+ raise Exception(f"读取本地图片失败: {trimmedPath}")
+ return f"data:{guessMimeTypeFromPath(trimmedPath)};base64,{base64Content}"
+
+
+async def resolveVideoImageInput(imageUrl, imagePath):
+ trimmedUrl = str(imageUrl or "").strip()
+ trimmedPath = str(imagePath or "").strip()
+ if trimmedUrl and trimmedPath:
+ raise Exception("image_url 和 image_path 只能二选一,请不要同时传。")
+ if trimmedUrl:
+ if not isProbablyUrl(trimmedUrl):
+ raise Exception("image_url 必须是 http 或 https 链接。")
+ return trimmedUrl
+ if trimmedPath:
+ return await readLocalImageAsDataUrl(trimmedPath)
+ return None
+
+
+async def executeJsonRequest(url, body):
+ apiKey = getApiKey()
+ request = client.newRequest().url(url).method("POST").headers({
+ "accept": "application/json",
+ "content-type": "application/json",
+ "Authorization": f"Bearer {apiKey}"
+ }).body(json.dumps(body), "json")
+
+ response = await request.build().execute()
+ if not response.isSuccessful():
+ raise Exception(f"HTTP {response.statusCode}: {response.content}")
+ try:
+ return json.loads(response.content)
+ except Exception as error:
+ raise Exception(f"解析响应 JSON 失败: {getErrorMessage(error)}")
+
+
+async def callImageApi(params):
+ apiBaseUrl = getApiBaseUrl(params.get("api_base_url"))
+ endpoint = getImageEndpoint(apiBaseUrl)
+
+ modelFromParam = str(params.get("model") or "").strip()
+ modelFromEnv = str(getEnv("SILICONFLOW_IMAGE_MODEL") or "").strip()
+ effectiveModel = modelFromParam or modelFromEnv or DEFAULT_IMAGE_MODEL
+
+ body = {
+ "model": effectiveModel,
+ "prompt": str(params.get("prompt") or "").strip()
+ }
+
+ imageSize = normalizeImageSize(params.get("image_size"), effectiveModel)
+ if imageSize:
+ body["image_size"] = imageSize
+
+ negativePrompt = str(params.get("negative_prompt") or "").strip()
+ if negativePrompt:
+ body["negative_prompt"] = negativePrompt
+
+ batchSize = parsePositiveInteger(params.get("batch_size"), "batch_size")
+ if batchSize is not None:
+ body["batch_size"] = batchSize
+
+ numInferenceSteps = parsePositiveInteger(params.get("num_inference_steps"), "num_inference_steps")
+ if numInferenceSteps is not None:
+ body["num_inference_steps"] = numInferenceSteps
+
+ guidanceScale = parseFiniteNumber(params.get("guidance_scale"), "guidance_scale")
+ if guidanceScale is not None:
+ body["guidance_scale"] = guidanceScale
+
+ seed = parseSeed(params.get("seed"))
+ if seed is not None:
+ body["seed"] = seed
+
+ parsed = await executeJsonRequest(endpoint, body)
+ images = parsed.get("images") if (isinstance(parsed, dict) and isinstance(parsed.get("images"), list)) else []
+ imageUrls = [str(item.get("url")).strip() for item in images if item and item.get("url")]
+ imageUrls = [u for u in imageUrls if len(u) > 0]
+
+ if len(imageUrls) == 0:
+ raise Exception("图片接口返回中未找到 images[].url,请检查模型与参数是否匹配。")
+
+ return {
+ "effective_model": effectiveModel,
+ "image_urls": imageUrls,
+ "seed": parsed.get("seed") if (isinstance(parsed, dict) and parsed.get("seed") is not None) else (seed if seed is not None else None)
+ }
+
+
+async def createVideoTask(params, imageInput=None):
+ apiBaseUrl = getApiBaseUrl(params.get("api_base_url"))
+ endpoint = getVideoSubmitEndpoint(apiBaseUrl)
+
+ modelFromParam = str(params.get("model") or "").strip()
+ modelFromEnv = str(getEnv("SILICONFLOW_VIDEO_MODEL") or "").strip()
+ defaultVideoModel = DEFAULT_VIDEO_IMAGE_MODEL if imageInput else DEFAULT_VIDEO_TEXT_MODEL
+ effectiveModel = modelFromParam or modelFromEnv or defaultVideoModel
+
+ if effectiveModel == DEFAULT_VIDEO_IMAGE_MODEL and not imageInput:
+ raise Exception(f"当前模型 {DEFAULT_VIDEO_IMAGE_MODEL} 为图生视频模型,必须传 image_url 或 image_path。")
+
+ body = {
+ "model": effectiveModel,
+ "prompt": str(params.get("prompt") or "").strip(),
+ "image_size": normalizeVideoSize(params.get("image_size"))
+ }
+
+ negativePrompt = str(params.get("negative_prompt") or "").strip()
+ if negativePrompt:
+ body["negative_prompt"] = negativePrompt
+
+ if imageInput:
+ body["image"] = imageInput
+
+ seed = parseSeed(params.get("seed"))
+ if seed is not None:
+ body["seed"] = seed
+
+ parsed = await executeJsonRequest(endpoint, body)
+ requestId = str(parsed.get("requestId")).strip() if (isinstance(parsed, dict) and parsed.get("requestId")) else ""
+ if not requestId:
+ raise Exception("视频接口返回中未找到 requestId。")
+
+ return {
+ "api_base_url": apiBaseUrl,
+ "effective_model": effectiveModel,
+ "request_id": requestId,
+ "seed": seed if seed is not None else None
+ }
+
+
+async def queryVideoStatus(apiBaseUrl, requestId):
+ endpoint = getVideoStatusEndpoint(apiBaseUrl)
+ parsed = await executeJsonRequest(endpoint, {"requestId": requestId})
+ status = str(parsed.get("status")).strip() if (isinstance(parsed, dict) and parsed.get("status")) else ""
+ reason = str(parsed.get("reason")).strip() if (isinstance(parsed, dict) and parsed.get("reason")) else ""
+ results = parsed.get("results") if isinstance(parsed, dict) else None
+ videos = results.get("videos") if (isinstance(results, dict) and isinstance(results.get("videos"), list)) else []
+ videoUrl = str(videos[0].get("url")).strip() if (len(videos) > 0 and videos[0] and videos[0].get("url")) else ""
+ seed = results.get("seed") if (isinstance(results, dict) and results.get("seed") is not None) else None
+ return {
+ "status": status,
+ "reason": reason,
+ "video_url": videoUrl,
+ "seed": seed
+ }
+
+
+def normalizeVideoStatus(status):
+ return str(status or "").strip().lower()
+
+
+async def draw_image(params):
+ prompt = str(params.get("prompt") if params and params.get("prompt") else "").strip()
+ if not prompt:
+ raise Exception("prompt 不能为空。")
+
+ await ensureDirectories()
+
+ apiResult = await callImageApi(params)
+ baseName = buildFileBaseName(prompt, params.get("file_name"), "siliconflow_image")
+ files = []
+
+ for index in range(len(apiResult["image_urls"])):
+ imageUrl = apiResult["image_urls"][index]
+ extension = guessExtensionFromUrl(imageUrl, "png")
+ suffix = f"_{index + 1}" if len(apiResult["image_urls"]) > 1 else ""
+ filePath = f"{DRAWS_DIR}/{baseName}{suffix}.{extension}"
+ downloadResult = await Tools.Files.download(imageUrl, filePath)
+ if not downloadResult.get("successful"):
+ raise Exception(f"下载图片失败: {downloadResult.get('details')}")
+ fileUri = f"file://{filePath}"
+ files.append({
+ "file_path": filePath,
+ "file_uri": fileUri,
+ "markdown": f"![AI生成的图片{f' {index + 1}' if len(apiResult['image_urls']) > 1 else ''}]({fileUri})"
+ })
+
+ hintLines = []
+ hintLines.append(f"图片已生成并下载到 {DRAWS_DIR}。")
+ hintLines.append(f"共生成 {len(files)} 张。")
+ hintLines.append("")
+ hintLines.append("后续回答如果需要展示图片,请直接输出下面这些 Markdown:")
+ hintLines.append("")
+ for fileItem in files:
+ hintLines.append(fileItem["markdown"])
+
+ return {
+ "prompt": prompt,
+ "model": apiResult["effective_model"],
+ "seed": apiResult["seed"],
+ "file_path": files[0]["file_path"],
+ "file_uri": files[0]["file_uri"],
+ "markdown": files[0]["markdown"],
+ "files": files,
+ "hint": "\n".join(hintLines)
+ }
+
+
+async def draw_video(params):
+ prompt = str(params.get("prompt") if params and params.get("prompt") else "").strip()
+ if not prompt:
+ raise Exception("prompt 不能为空。")
+
+ await ensureDirectories()
+
+ imageInput = await resolveVideoImageInput(params.get("image_url"), params.get("image_path"))
+ createdTask = await createVideoTask(params, imageInput)
+ pollIntervalMs = parsePositiveInteger(params.get("poll_interval_ms"), "poll_interval_ms") or DEFAULT_POLL_INTERVAL_MS
+ maxWaitTimeMs = parsePositiveInteger(params.get("max_wait_time_ms"), "max_wait_time_ms") or DEFAULT_MAX_WAIT_TIME_MS
+ deadline = int(time.time() * 1000) + maxWaitTimeMs
+
+ latestStatus = ""
+ latestReason = ""
+ remoteVideoUrl = ""
+ resultSeed = createdTask["seed"]
+
+ while int(time.time() * 1000) <= deadline:
+ statusResult = await queryVideoStatus(createdTask["api_base_url"], createdTask["request_id"])
+ latestStatus = statusResult["status"]
+ latestReason = statusResult["reason"]
+ resultSeed = statusResult["seed"] if (statusResult["seed"] is not None) else resultSeed
+
+ normalizedStatus = normalizeVideoStatus(statusResult["status"])
+ if normalizedStatus == "succeed":
+ remoteVideoUrl = statusResult["video_url"]
+ if not remoteVideoUrl:
+ raise Exception("视频任务已成功,但返回中未找到 results.videos[0].url。")
+ break
+
+ if normalizedStatus == "failed":
+ raise Exception(f"视频生成失败: {latestReason or '接口未返回失败原因'}")
+
+ await Tools.System.sleep(pollIntervalMs)
+
+ if not remoteVideoUrl:
+ raise Exception(f"视频生成超时,最后状态为 {latestStatus or '未知'}{f',原因:{latestReason}' if latestReason else ''}")
+
+ baseName = buildFileBaseName(prompt, params.get("file_name"), "siliconflow_video")
+ extension = guessExtensionFromUrl(remoteVideoUrl, "mp4")
+ filePath = f"{VIDEOS_DIR}/{baseName}.{extension}"
+ downloadResult = await Tools.Files.download(remoteVideoUrl, filePath)
+ if not downloadResult.get("successful"):
+ raise Exception(f"下载视频失败: {downloadResult.get('details')}")
+
+ fileUri = f"file://{filePath}"
+ markdownLink = f"[点击查看生成的视频]({fileUri})"
+ htmlVideo = f''
+ hintLines = []
+ hintLines.append(f"视频已生成并下载到 {VIDEOS_DIR}。")
+ hintLines.append(f"本地路径: {filePath}")
+ hintLines.append("")
+ hintLines.append("后续回答如果要给出视频,请优先返回这个本地链接:")
+ hintLines.append(markdownLink)
+ hintLines.append("")
+ hintLines.append("如果当前渲染环境支持 HTML 视频标签,也可以使用:")
+ hintLines.append(htmlVideo)
+
+ return {
+ "prompt": prompt,
+ "model": createdTask["effective_model"],
+ "request_id": createdTask["request_id"],
+ "seed": resultSeed,
+ "file_path": filePath,
+ "file_uri": fileUri,
+ "remote_video_url": remoteVideoUrl,
+ "markdown_link": markdownLink,
+ "html_video": htmlVideo,
+ "hint": "\n".join(hintLines)
+ }
+
+
+async def draw_image_wrapper(params=None):
+ if params is None:
+ params = {}
+ try:
+ result = await draw_image(params)
+ complete({
+ "success": True,
+ "message": "SiliconFlow 图片生成成功,已下载到本地。",
+ "data": result
+ })
+ except Exception as error:
+ console.error("draw_image 执行失败:", error)
+ complete({
+ "success": False,
+ "message": f"SiliconFlow 图片生成失败: {getErrorMessage(error)}",
+ "error_stack": traceback.format_exc()
+ })
+
+
+async def draw_video_wrapper(params=None):
+ if params is None:
+ params = {}
+ try:
+ result = await draw_video(params)
+ complete({
+ "success": True,
+ "message": "SiliconFlow 视频生成成功,已下载到本地。",
+ "data": result
+ })
+ except Exception as error:
+ console.error("draw_video 执行失败:", error)
+ complete({
+ "success": False,
+ "message": f"SiliconFlow 视频生成失败: {getErrorMessage(error)}",
+ "error_stack": traceback.format_exc()
+ })
+
+
+exports.draw_image = draw_image_wrapper
+exports.draw_video = draw_video_wrapper
diff --git a/operit_py/super_admin.py b/operit_py/super_admin.py
new file mode 100644
index 0000000..89b9321
--- /dev/null
+++ b/operit_py/super_admin.py
@@ -0,0 +1,337 @@
+# METADATA
+# {
+# "name": "super_admin",
+# "display_name": {
+# "zh": "超级管理员",
+# "en": "Super Admin"
+# },
+# "description": { "zh": "超级管理员工具集,提供终端命令和Shell操作的高级功能。terminal工具运行在Ubuntu环境中(已正确挂载sdcard和storage),shell工具通过Shizuku/Root直接执行Android系统命令。适合需要进行底层系统管理和命令行操作的场景。", "en": "Super admin toolkit providing advanced terminal and shell capabilities. The terminal tool runs in an Ubuntu environment (with sdcard/storage mounted). The shell tool executes Android system commands directly via Shizuku/Root. Useful for low-level system administration and CLI operations." },
+# "enabledByDefault": true,
+# "category": "System",
+# "tools": [
+# {
+# "name": "terminal",
+# "description": { "zh": "在Ubuntu环境中执行命令并收集输出结果。运行环境:完整的Ubuntu系统,已正确挂载sdcard和storage目录,可访问Android存储空间。所有命令将会在相同的会话执行且上下文连贯。强烈建议每次都显式传 timeoutMs,避免命令卡住。禁止使用 `set -e`、`set -o errexit` 等会改变 shell 退出行为的命令,这会导致终端会话直接退出并卡死。若未传,前台默认15秒超时;background=true 时不使用该默认超时。命令超时时不会被自动取消,不需要重新执行命令,请继续通过 terminal_getscreen 跟踪当前屏幕内容。", "en": "Execute commands in an Ubuntu environment and collect output. Environment: full Ubuntu system with sdcard/storage mounted, allowing access to Android storage. Automatically preserves working-directory context. Strongly recommend explicitly passing timeoutMs every time to avoid hangs. Do not use commands such as `set -e` or `set -o errexit` that change shell exit behavior, because they can cause the terminal session to exit and hang. If omitted, foreground mode defaults to 15s timeout; background=true does not use this default timeout. When a command times out, it is not automatically cancelled. Do not rerun the command; continue tracking the current screen via terminal_getscreen." },
+# "parameters": [
+# { "name": "command", "description": { "zh": "要执行的命令", "en": "Command to execute." }, "type": "string", "required": true },
+# { "name": "background", "description": { "zh": "是否在后台运行命令,\"true\" 表示后台执行并立即返回,适合启动服务器等长时间运行的任务(AI 不会收到该命令的输出结果),\"false\" 或未提供则前台执行并等待并返回命令结果", "en": "Run command in background. 'true' runs in background and returns immediately (good for long-running tasks like servers; AI will not receive output). 'false' or omitted runs in foreground and returns the command result." }, "type": "string", "required": false },
+# { "name": "timeoutMs", "description": { "zh": "可选超时(毫秒,最低3000ms)。强烈建议显式传入;未传时前台默认15000ms,background=true时不使用默认超时。", "en": "Optional timeout (ms, minimum 3000ms). Strongly recommended to pass explicitly; if omitted, foreground defaults to 15000ms, and background=true does not use the default timeout." }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "terminal_wait",
+# "description": { "zh": "等待同一终端会话中的上一条命令执行完成。适用于安装/编译等长命令在 timeout 后继续后台执行的场景。与 sleep 不同,本工具会在命令实际完成时提前返回,而不是固定睡眠。", "en": "Wait until the previous command in the same terminal session finishes. Useful when long install/build commands continue running after a timeout. Unlike sleep, this tool can return early as soon as the command actually completes." },
+# "parameters": [
+# { "name": "sessionId", "description": { "zh": "可选目标会话ID。不传则使用当前对话的默认会话;无 chatId 时为 super_admin_default_session。", "en": "Optional target session ID. If omitted, uses the current chat's default session; without chatId it is super_admin_default_session." }, "type": "string", "required": false },
+# { "name": "timeoutMs", "description": { "zh": "可选超时(毫秒,最低3000ms)。未传时默认300000ms(5分钟)。", "en": "Optional timeout (ms, minimum 3000ms). Defaults to 300000ms (5 minutes) if omitted." }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "terminal_getscreen",
+# "description": { "zh": "获取当前终端会话可见屏幕内容(仅一屏,不包含历史滚动缓冲)。", "en": "Get the current visible screen content for the active terminal session (single screen only, no scrollback history)." },
+# "parameters": []
+# },
+# {
+# "name": "terminal_input",
+# "description": { "zh": "向当前终端会话写入输入。input 与 control 至少传一个。常见用法:先写 input,再写 control=enter 提交;control=ctrl 且 input=c 可发送 Ctrl+C。", "en": "Write input to the active terminal session. Provide at least one of input or control. Typical usage: send input first, then control=enter to submit; use control=ctrl with input=c for Ctrl+C." },
+# "parameters": [
+# { "name": "input", "description": { "zh": "写入终端的文本", "en": "Text to write to terminal." }, "type": "string", "required": false },
+# { "name": "control", "description": { "zh": "控制键,例如 enter / tab / esc / ctrl", "en": "Control key, e.g. enter / tab / esc / ctrl." }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "shell",
+# "description": { "zh": "通过Shizuku/Root权限直接在Android系统中执行Shell命令。运行环境:直接访问Android系统,具有系统级权限,适用于需要操作Android系统底层的场景(如pm、am等系统命令)。", "en": "Execute shell commands directly on Android with Shizuku/Root. Environment: direct Android system access with system-level privileges, suitable for low-level commands such as pm/am." },
+# "parameters": [
+# { "name": "command", "description": { "zh": "要执行的Shell命令", "en": "Shell command to execute." }, "type": "string", "required": true }
+# ]
+# }
+# ]
+# }
+
+import asyncio
+import datetime
+import math
+import random
+import re
+import time
+import traceback
+
+MAX_INLINE_TERMINAL_OUTPUT_CHARS = 12000
+DEFAULT_FOREGROUND_TIMEOUT_MS = 15000
+DEFAULT_WAIT_TIMEOUT_MS = 300000
+MIN_TIMEOUT_MS = 3000
+DEFAULT_TERMINAL_SESSION_NAME = "super_admin_default_session"
+BACKGROUND_TERMINAL_SESSION_PREFIX = "super_admin_background"
+
+
+def _is_finite(n):
+ return isinstance(n, (int, float)) and not isinstance(n, bool) and math.isfinite(n)
+
+
+def getCurrentChatSessionSuffix():
+ chatId = getChatId()
+ if chatId is None:
+ return ""
+ normalizedChatId = (chatId or "").strip()
+ if not normalizedChatId:
+ return ""
+ return re.sub(r"[^a-zA-Z0-9._-]+", "_", normalizedChatId)
+
+
+def getDefaultTerminalSessionName():
+ chatSuffix = getCurrentChatSessionSuffix()
+ return f"{DEFAULT_TERMINAL_SESSION_NAME}_{chatSuffix}" if chatSuffix else DEFAULT_TERMINAL_SESSION_NAME
+
+
+def getBackgroundTerminalSessionName():
+ chatSuffix = getCurrentChatSessionSuffix()
+ prefix = f"{BACKGROUND_TERMINAL_SESSION_PREFIX}_{chatSuffix}" if chatSuffix else BACKGROUND_TERMINAL_SESSION_PREFIX
+ return f"{prefix}_{int(time.time() * 1000)}"
+
+
+async def persistTerminalOutputIfTooLong(command, result):
+ outputStr = result.get("output") if isinstance(result, dict) else None
+ if not isinstance(outputStr, str):
+ outputStr = str((result.get("output") if isinstance(result, dict) else "") or "")
+
+ if len(outputStr) <= MAX_INLINE_TERMINAL_OUTPUT_CHARS:
+ return None
+
+ await Tools.Files.mkdir(OPERIT_CLEAN_ON_EXIT_DIR, True)
+
+ timestamp = datetime.datetime.utcnow().isoformat().replace(":", "-").replace(".", "-")
+ rand = int(random.random() * 1000000)
+ filePath = f"{OPERIT_CLEAN_ON_EXIT_DIR}/terminal_output_{timestamp}_{rand}.log"
+
+ await Tools.Files.write(filePath, outputStr, False)
+
+ return {
+ "command": command,
+ "output": "(saved_to_file)",
+ "exitCode": result.get("exitCode") if isinstance(result, dict) else None,
+ "sessionId": result.get("sessionId") if isinstance(result, dict) else None,
+ "context_preserved": True,
+ "output_saved_to": filePath,
+ "output_chars": len(outputStr),
+ "operit_clean_on_exit_dir": OPERIT_CLEAN_ON_EXIT_DIR,
+ "hint": "Output is large and saved to file. Use read_file_part or grep_code to inspect it.",
+ }
+
+
+async def terminal(params):
+ try:
+ if not params.get("command"):
+ raise Exception("命令不能为空")
+
+ command = params["command"]
+ background = params.get("background")
+ timeoutMs = params.get("timeoutMs")
+
+ console.log(f"执行终端命令: {command}")
+
+ isBackground = background == "true"
+ timeout = None
+ if not isBackground:
+ if timeoutMs is not None:
+ parsedTimeout = int(timeoutMs)
+ if not _is_finite(parsedTimeout) or parsedTimeout < MIN_TIMEOUT_MS:
+ raise Exception(f"timeoutMs必须是整数且不少于{MIN_TIMEOUT_MS}毫秒")
+ timeout = parsedTimeout
+ else:
+ timeout = DEFAULT_FOREGROUND_TIMEOUT_MS
+
+ if isBackground:
+ session = await Tools.System.terminal.create(getBackgroundTerminalSessionName())
+ sessionId = session["sessionId"]
+
+ # 调用系统工具执行终端命令(后台运行,不等待)
+ async def _bg_exec():
+ try:
+ await Tools.System.terminal.exec(sessionId, command)
+ except Exception as error:
+ console.error(f"[terminal/background] 错误: {str(error)}")
+ console.error(traceback.format_exc())
+
+ asyncio.ensure_future(_bg_exec())
+
+ return {
+ "command": command,
+ "background": True,
+ "sessionId": sessionId,
+ "started": True
+ }
+
+ # 创建或获取一个默认会话
+ session = await Tools.System.terminal.create(getDefaultTerminalSessionName())
+ sessionId = session["sessionId"]
+
+ # 调用系统工具执行终端命令
+ result = await Tools.System.terminal.exec(sessionId, command, timeout)
+ timedOut = result.get("timedOut") is True
+
+ timeoutScreen = None
+ if timedOut:
+ screenResult = await Tools.System.terminal.screen(sessionId)
+ timeoutScreen = {
+ "sessionId": screenResult.get("sessionId") or sessionId,
+ "rows": screenResult.get("rows"),
+ "cols": screenResult.get("cols"),
+ "content": screenResult.get("content")
+ }
+
+ persistedResult = await persistTerminalOutputIfTooLong(command, result)
+ if persistedResult:
+ persistedResult["timeoutMsUsed"] = timeout
+ if timeoutScreen:
+ persistedResult["timeoutScreen"] = timeoutScreen
+ return persistedResult
+
+ return {
+ "command": command,
+ "output": result.get("output"),
+ "exitCode": result.get("exitCode"),
+ "sessionId": result.get("sessionId"),
+ "timedOut": timedOut,
+ "timeoutMsUsed": timeout,
+ "timeoutScreen": timeoutScreen,
+ "context_preserved": True # 标记此命令保留了目录上下文
+ }
+ except Exception as error:
+ console.error(f"[terminal] 错误: {str(error)}")
+ console.error(traceback.format_exc())
+
+ raise error
+
+
+async def terminal_wait(params=None):
+ if params is None:
+ params = {}
+ try:
+ timeoutMs = params.get("timeoutMs")
+ timeout = DEFAULT_WAIT_TIMEOUT_MS
+ if timeoutMs is not None:
+ parsedTimeout = int(timeoutMs)
+ if not _is_finite(parsedTimeout) or parsedTimeout < MIN_TIMEOUT_MS:
+ raise Exception(f"timeoutMs必须是整数且不少于{MIN_TIMEOUT_MS}毫秒")
+ timeout = parsedTimeout
+
+ if params.get("sessionId"):
+ session = {"sessionId": params["sessionId"]}
+ else:
+ session = await Tools.System.terminal.create(getDefaultTerminalSessionName())
+ sessionId = session["sessionId"]
+
+ marker = f"__OPERIT_TERMINAL_WAIT_DONE_{int(time.time() * 1000)}_{int(random.random() * 1000000)}__"
+ waitCommand = f"printf '{marker}\\n'"
+ startedAt = int(time.time() * 1000)
+ result = await Tools.System.terminal.exec(sessionId, waitCommand, timeout)
+ elapsedMs = int(time.time() * 1000) - startedAt
+ timedOut = (result or {}).get("timedOut") is True
+
+ timeoutScreen = None
+ if timedOut:
+ screenResult = await Tools.System.terminal.screen(sessionId)
+ timeoutScreen = {
+ "sessionId": screenResult.get("sessionId") or sessionId,
+ "rows": screenResult.get("rows"),
+ "cols": screenResult.get("cols"),
+ "content": screenResult.get("content")
+ }
+
+ outputStr = (result or {}).get("output")
+ if not isinstance(outputStr, str):
+ outputStr = str((result or {}).get("output") or "")
+ markerSeen = marker in outputStr
+
+ return {
+ "sessionId": sessionId,
+ "timedOut": timedOut,
+ "timeoutMsUsed": timeout,
+ "elapsedMs": elapsedMs,
+ "waitCompleted": (not timedOut) and markerSeen,
+ "markerSeen": markerSeen,
+ "exitCode": (result or {}).get("exitCode"),
+ "timeoutScreen": timeoutScreen,
+ "context_preserved": True
+ }
+ except Exception as error:
+ console.error(f"[terminal_wait] 错误: {str(error)}")
+ console.error(traceback.format_exc())
+ raise error
+
+
+async def shell(params):
+ try:
+ if not params.get("command"):
+ raise Exception("命令不能为空")
+ command = params["command"]
+
+ console.log(f"执行Shell命令: {command}")
+
+ # 通过Shizuku/Root权限执行shell操作
+ result = await Tools.System.shell(f"{command}")
+
+ return {
+ "command": command,
+ "output": result.get("output") if isinstance(result, dict) else result,
+ "exitCode": result.get("exitCode") if isinstance(result, dict) else None
+ }
+ except Exception as error:
+ console.error(f"[shell] 错误: {str(error)}")
+ console.error(traceback.format_exc())
+
+ raise error
+
+
+async def terminal_getscreen(params=None):
+ if params is None:
+ params = {}
+ try:
+ session = await Tools.System.terminal.create(getDefaultTerminalSessionName())
+ sessionId = session["sessionId"]
+ result = await Tools.System.terminal.screen(sessionId)
+ return {
+ "sessionId": result.get("sessionId") or sessionId,
+ "rows": result.get("rows"),
+ "cols": result.get("cols"),
+ "content": result.get("content"),
+ "commandRunning": result.get("commandRunning") is True
+ }
+ except Exception as error:
+ console.error(f"[terminal_getscreen] 错误: {str(error)}")
+ console.error(traceback.format_exc())
+ raise error
+
+
+async def terminal_input(params=None):
+ if params is None:
+ params = {}
+ try:
+ if params.get("input") is None and params.get("control") is None:
+ raise Exception("input和control至少需要提供一个")
+
+ session = await Tools.System.terminal.create(getDefaultTerminalSessionName())
+ sessionId = session["sessionId"]
+ result = await Tools.System.terminal.input(sessionId, {
+ "input": params.get("input"),
+ "control": params.get("control")
+ })
+
+ return {
+ "sessionId": sessionId,
+ "input": params.get("input"),
+ "control": params.get("control"),
+ "result": (result.get("value") if isinstance(result, dict) and result.get("value") is not None else str(result if result is not None else ""))
+ }
+ except Exception as error:
+ console.error(f"[terminal_input] 错误: {str(error)}")
+ console.error(traceback.format_exc())
+ raise error
+
+
+exports.terminal = terminal
+exports.terminal_wait = terminal_wait
+exports.terminal_getscreen = terminal_getscreen
+exports.terminal_input = terminal_input
+exports.shell = shell
diff --git a/operit_py/system_tools.py b/operit_py/system_tools.py
new file mode 100644
index 0000000..21b7197
--- /dev/null
+++ b/operit_py/system_tools.py
@@ -0,0 +1,576 @@
+# METADATA
+# {
+# "name": "system_tools",
+# "display_name": {"zh": "系统工具", "en": "System Tools"},
+# "description": {"zh": "提供系统级操作工具,包括设置管理、应用安装卸载与启动、通知获取、位置服务、设备信息查询,以及 Intent/广播调用。", "en": "System-level operations: settings management, app install/uninstall & launch, notification retrieval, location services, device info queries, plus Intent/broadcast execution."},
+# "enabledByDefault": True,
+# "category": "System",
+# "tools": [
+# {"name": "get_system_setting", "description": {"zh": "获取系统设置的值。需要用户授权。", "en": "Get the value of a system setting. Requires user authorization."}, "parameters": [
+# {"name": "setting", "description": {"zh": "设置名称", "en": "Setting key/name"}, "type": "string", "required": True},
+# {"name": "namespace", "description": {"zh": "命名空间:system/secure/global,默认system", "en": "Namespace: system/secure/global (default: system)"}, "type": "string", "required": False}
+# ]},
+# {"name": "modify_system_setting", "description": {"zh": "修改系统设置的值。需要用户授权。", "en": "Modify the value of a system setting. Requires user authorization."}, "parameters": [
+# {"name": "setting", "description": {"zh": "设置名称", "en": "Setting key/name"}, "type": "string", "required": True},
+# {"name": "value", "description": {"zh": "设置值", "en": "Setting value"}, "type": "string", "required": True},
+# {"name": "namespace", "description": {"zh": "命名空间:system/secure/global,默认system", "en": "Namespace: system/secure/global (default: system)"}, "type": "string", "required": False}
+# ]},
+# {"name": "install_app", "description": {"zh": "安装应用程序。需要用户授权。", "en": "Install an app. Requires user authorization."}, "parameters": [
+# {"name": "path", "description": {"zh": "APK文件路径", "en": "APK file path"}, "type": "string", "required": True}
+# ]},
+# {"name": "uninstall_app", "description": {"zh": "卸载应用程序。需要用户授权。", "en": "Uninstall an app. Requires user authorization."}, "parameters": [
+# {"name": "package_name", "description": {"zh": "应用包名", "en": "App package name"}, "type": "string", "required": True},
+# {"name": "keep_data", "description": {"zh": "是否保留数据,默认false", "en": "Whether to keep app data (default: false)"}, "type": "boolean", "required": False}
+# ]},
+# {"name": "list_installed_apps", "description": {"zh": "获取已安装应用程序列表。需要用户授权。", "en": "List installed apps. Requires user authorization."}, "parameters": [
+# {"name": "include_system_apps", "description": {"zh": "是否包含系统应用,默认false", "en": "Whether to include system apps (default: false)"}, "type": "boolean", "required": False}
+# ]},
+# {"name": "start_app", "description": {"zh": "启动应用程序。需要用户授权。", "en": "Launch an app. Requires user authorization."}, "parameters": [
+# {"name": "package_name", "description": {"zh": "应用包名", "en": "App package name"}, "type": "string", "required": True},
+# {"name": "activity", "description": {"zh": "可选活动名称", "en": "Optional activity name"}, "type": "string", "required": False}
+# ]},
+# {"name": "stop_app", "description": {"zh": "停止正在运行的应用程序。需要用户授权。", "en": "Force stop a running app. Requires user authorization."}, "parameters": [
+# {"name": "package_name", "description": {"zh": "应用包名", "en": "App package name"}, "type": "string", "required": True}
+# ]},
+# {"name": "send_broadcast", "description": {"zh": "发送广播(Broadcast Intent)。需要用户授权。", "en": "Send a broadcast (Broadcast Intent). Requires user authorization."}, "parameters": [
+# {"name": "action", "description": {"zh": "Intent action,例如 android.intent.action.VIEW", "en": "Intent action, e.g. android.intent.action.VIEW"}, "type": "string", "required": True},
+# {"name": "package_name", "description": {"zh": "可选:限制广播目标包名", "en": "Optional: restrict target package"}, "type": "string", "required": False},
+# {"name": "component", "description": {"zh": "可选:组件名 package/class,优先于package_name", "en": "Optional: component package/class, takes priority over package_name"}, "type": "string", "required": False},
+# {"name": "uri", "description": {"zh": "可选:data uri", "en": "Optional: data uri"}, "type": "string", "required": False},
+# {"name": "extras", "description": {"zh": "可选:extras(对象,可用于传参)", "en": "Optional: extras (object for parameters)"}, "type": "object", "required": False}
+# ]},
+# {"name": "execute_intent", "description": {"zh": "执行 Intent(Activity/Service/Broadcast),支持 extras 传参。需要用户授权。", "en": "Execute an Intent (Activity/Service/Broadcast) with extras parameters. Requires user authorization."}, "parameters": [
+# {"name": "type", "description": {"zh": "类型:activity/broadcast/service,默认activity", "en": "Type: activity/broadcast/service (default: activity)"}, "type": "string", "required": False},
+# {"name": "action", "description": {"zh": "Intent action(action 或 component 至少一个必填)", "en": "Intent action (either action or component is required)"}, "type": "string", "required": False},
+# {"name": "package_name", "description": {"zh": "可选:包名", "en": "Optional: package name"}, "type": "string", "required": False},
+# {"name": "component", "description": {"zh": "可选:组件名 package/class", "en": "Optional: component package/class"}, "type": "string", "required": False},
+# {"name": "uri", "description": {"zh": "可选:data uri", "en": "Optional: data uri"}, "type": "string", "required": False},
+# {"name": "flags", "description": {"zh": "可选:flags(整数或JSON数组字符串)", "en": "Optional: flags (integer or JSON array string)"}, "type": "string", "required": False},
+# {"name": "extras", "description": {"zh": "可选:extras(对象,可用于传参)", "en": "Optional: extras (object for parameters)"}, "type": "object", "required": False}
+# ]},
+# {"name": "get_notifications", "description": {"zh": "获取设备通知内容。", "en": "Retrieve device notifications."}, "parameters": [
+# {"name": "limit", "description": {"zh": "最大返回条数,默认10", "en": "Max number of entries to return (default: 10)"}, "type": "number", "required": False},
+# {"name": "include_ongoing", "description": {"zh": "是否包含常驻通知,默认false", "en": "Whether to include ongoing notifications (default: false)"}, "type": "boolean", "required": False}
+# ]},
+# {"name": "get_app_usage_time", "description": {"zh": "获取应用前台使用时长。需要授予"使用情况访问权限"。", "en": "Get app foreground usage time. Requires Usage Access permission."}, "parameters": [
+# {"name": "package_name", "description": {"zh": "可选:精确应用包名", "en": "Optional exact package name"}, "type": "string", "required": False},
+# {"name": "since_hours", "description": {"zh": "向前统计多少小时,默认24", "en": "How many hours to look back (default: 24)"}, "type": "number", "required": False},
+# {"name": "limit", "description": {"zh": "不传包名时最多返回多少个应用,默认10", "en": "Max apps to return when package_name is omitted (default: 10)"}, "type": "number", "required": False},
+# {"name": "include_system_apps", "description": {"zh": "不传包名时是否包含系统应用,默认false", "en": "Whether to include system apps when package_name is omitted (default: false)"}, "type": "boolean", "required": False}
+# ]},
+# {"name": "get_device_location", "description": {"zh": "获取设备当前位置信息。", "en": "Get current device location."}, "parameters": [
+# {"name": "high_accuracy", "description": {"zh": "是否使用高精度模式,默认false", "en": "Use high accuracy mode (default: false)"}, "type": "boolean", "required": False},
+# {"name": "timeout", "description": {"zh": "超时时间(秒),默认10", "en": "Timeout in seconds (default: 10)"}, "type": "number", "required": False}
+# ]},
+# {"name": "request_bluetooth_permission", "description": {"zh": "请求蓝牙附近设备权限。", "en": "Request Bluetooth nearby devices permission."}, "parameters": []},
+# {"name": "get_bluetooth_state", "description": {"zh": "获取蓝牙适配器状态。", "en": "Get Bluetooth adapter state."}, "parameters": []},
+# {"name": "request_enable_bluetooth", "description": {"zh": "打开系统蓝牙开启对话框。", "en": "Open the system dialog to enable Bluetooth."}, "parameters": []},
+# {"name": "list_bluetooth_bonded_devices", "description": {"zh": "列出已配对蓝牙设备。", "en": "List bonded Bluetooth devices."}, "parameters": []},
+# {"name": "scan_bluetooth_devices", "description": {"zh": "扫描附近蓝牙 Classic 与 BLE 设备。", "en": "Scan nearby Bluetooth classic and BLE devices."}, "parameters": [
+# {"name": "duration_ms", "description": {"zh": "扫描时长毫秒。", "en": "Scan duration in milliseconds."}, "type": "number", "required": False},
+# {"name": "include_ble", "description": {"zh": "是否包含 BLE 扫描。", "en": "Whether to include BLE scanning."}, "type": "boolean", "required": False}
+# ]},
+# {"name": "bluetooth_connect", "description": {"zh": "连接蓝牙 Classic 设备。", "en": "Connect to a Bluetooth classic device."}, "parameters": [
+# {"name": "address", "description": {"zh": "蓝牙 MAC 地址", "en": "Bluetooth MAC address"}, "type": "string", "required": True},
+# {"name": "uuid", "description": {"zh": "RFCOMM UUID。", "en": "RFCOMM UUID."}, "type": "string", "required": False}
+# ]},
+# {"name": "bluetooth_listen", "description": {"zh": "监听别人连接本机的蓝牙 Classic 通道。", "en": "Listen for another device connecting to this phone over Bluetooth classic."}, "parameters": [
+# {"name": "name", "description": {"zh": "服务名。", "en": "Service name."}, "type": "string", "required": False},
+# {"name": "uuid", "description": {"zh": "RFCOMM UUID。", "en": "RFCOMM UUID."}, "type": "string", "required": False}
+# ]},
+# {"name": "bluetooth_accept", "description": {"zh": "接受蓝牙 Classic 监听会话中的一个传入连接。", "en": "Accept one incoming connection from a Bluetooth classic listener."}, "parameters": [
+# {"name": "listener_session_id", "description": {"zh": "监听会话 ID", "en": "Listener session ID"}, "type": "string", "required": True},
+# {"name": "timeout_ms", "description": {"zh": "等待毫秒数。", "en": "Wait time in milliseconds."}, "type": "number", "required": False}
+# ]},
+# {"name": "bluetooth_send", "description": {"zh": "向蓝牙 Classic 会话发送文本或 base64 字节。", "en": "Send text or base64 bytes to a Bluetooth classic session."}, "parameters": [
+# {"name": "session_id", "description": {"zh": "会话 ID", "en": "Session ID"}, "type": "string", "required": True},
+# {"name": "text", "description": {"zh": "UTF-8 文本", "en": "UTF-8 text"}, "type": "string", "required": False},
+# {"name": "data_base64", "description": {"zh": "base64 字节", "en": "Base64 bytes"}, "type": "string", "required": False}
+# ]},
+# {"name": "bluetooth_read", "description": {"zh": "从蓝牙 Classic 会话读取文本或字节。", "en": "Read text or bytes from a Bluetooth classic session."}, "parameters": [
+# {"name": "session_id", "description": {"zh": "会话 ID", "en": "Session ID"}, "type": "string", "required": True},
+# {"name": "max_bytes", "description": {"zh": "最大读取字节数。", "en": "Maximum bytes to read."}, "type": "number", "required": False},
+# {"name": "timeout_ms", "description": {"zh": "等待毫秒数。", "en": "Wait time in milliseconds."}, "type": "number", "required": False}
+# ]},
+# {"name": "bluetooth_send_and_read", "description": {"zh": "向蓝牙 Classic 会话发送后读取响应。", "en": "Send to a Bluetooth classic session and read the response."}, "parameters": [
+# {"name": "session_id", "description": {"zh": "会话 ID", "en": "Session ID"}, "type": "string", "required": True},
+# {"name": "text", "description": {"zh": "UTF-8 文本", "en": "UTF-8 text"}, "type": "string", "required": False},
+# {"name": "data_base64", "description": {"zh": "base64 字节", "en": "Base64 bytes"}, "type": "string", "required": False},
+# {"name": "max_bytes", "description": {"zh": "最大读取字节数。", "en": "Maximum bytes to read."}, "type": "number", "required": False},
+# {"name": "timeout_ms", "description": {"zh": "等待毫秒数。", "en": "Wait time in milliseconds."}, "type": "number", "required": False}
+# ]},
+# {"name": "bluetooth_close", "description": {"zh": "关闭蓝牙 Classic、监听或 BLE 会话。", "en": "Close a Bluetooth classic, listener, or BLE session."}, "parameters": [
+# {"name": "session_id", "description": {"zh": "会话 ID", "en": "Session ID"}, "type": "string", "required": True}
+# ]},
+# {"name": "bluetooth_ble_connect", "description": {"zh": "连接 BLE 设备。", "en": "Connect to a BLE device."}, "parameters": [
+# {"name": "address", "description": {"zh": "蓝牙 MAC 地址", "en": "Bluetooth MAC address"}, "type": "string", "required": True},
+# {"name": "auto_connect", "description": {"zh": "是否自动连接。", "en": "Whether to use auto connect."}, "type": "boolean", "required": False}
+# ]},
+# {"name": "bluetooth_ble_discover_services", "description": {"zh": "发现 BLE 服务和 characteristic。", "en": "Discover BLE services and characteristics."}, "parameters": [
+# {"name": "session_id", "description": {"zh": "BLE 会话 ID", "en": "BLE session ID"}, "type": "string", "required": True},
+# {"name": "timeout_ms", "description": {"zh": "等待毫秒数。", "en": "Wait time in milliseconds."}, "type": "number", "required": False}
+# ]},
+# {"name": "bluetooth_ble_read_characteristic", "description": {"zh": "读取 BLE characteristic。", "en": "Read a BLE characteristic."}, "parameters": [
+# {"name": "session_id", "description": {"zh": "BLE 会话 ID", "en": "BLE session ID"}, "type": "string", "required": True},
+# {"name": "service_uuid", "description": {"zh": "Service UUID", "en": "Service UUID"}, "type": "string", "required": True},
+# {"name": "characteristic_uuid", "description": {"zh": "Characteristic UUID", "en": "Characteristic UUID"}, "type": "string", "required": True},
+# {"name": "timeout_ms", "description": {"zh": "等待毫秒数。", "en": "Wait time in milliseconds."}, "type": "number", "required": False}
+# ]},
+# {"name": "bluetooth_ble_write_characteristic", "description": {"zh": "写入 BLE characteristic。", "en": "Write a BLE characteristic."}, "parameters": [
+# {"name": "session_id", "description": {"zh": "BLE 会话 ID", "en": "BLE session ID"}, "type": "string", "required": True},
+# {"name": "service_uuid", "description": {"zh": "Service UUID", "en": "Service UUID"}, "type": "string", "required": True},
+# {"name": "characteristic_uuid", "description": {"zh": "Characteristic UUID", "en": "Characteristic UUID"}, "type": "string", "required": True},
+# {"name": "text", "description": {"zh": "UTF-8 文本", "en": "UTF-8 text"}, "type": "string", "required": False},
+# {"name": "data_base64", "description": {"zh": "base64 字节", "en": "Base64 bytes"}, "type": "string", "required": False}
+# ]},
+# {"name": "bluetooth_ble_write_and_read_characteristic", "description": {"zh": "写入 BLE characteristic 后读取另一个 characteristic 响应。", "en": "Write a BLE characteristic and read another characteristic response."}, "parameters": [
+# {"name": "session_id", "description": {"zh": "BLE 会话 ID", "en": "BLE session ID"}, "type": "string", "required": True},
+# {"name": "write_service_uuid", "description": {"zh": "写入 Service UUID", "en": "Write service UUID"}, "type": "string", "required": True},
+# {"name": "write_characteristic_uuid", "description": {"zh": "写入 Characteristic UUID", "en": "Write characteristic UUID"}, "type": "string", "required": True},
+# {"name": "read_service_uuid", "description": {"zh": "读取 Service UUID", "en": "Read service UUID"}, "type": "string", "required": True},
+# {"name": "read_characteristic_uuid", "description": {"zh": "读取 Characteristic UUID", "en": "Read characteristic UUID"}, "type": "string", "required": True},
+# {"name": "text", "description": {"zh": "UTF-8 文本", "en": "UTF-8 text"}, "type": "string", "required": False},
+# {"name": "data_base64", "description": {"zh": "base64 字节", "en": "Base64 bytes"}, "type": "string", "required": False},
+# {"name": "timeout_ms", "description": {"zh": "等待毫秒数。", "en": "Wait time in milliseconds."}, "type": "number", "required": False}
+# ]},
+# {"name": "bluetooth_ble_subscribe_characteristic", "description": {"zh": "订阅或取消订阅 BLE characteristic 通知。", "en": "Subscribe or unsubscribe BLE characteristic notifications."}, "parameters": [
+# {"name": "session_id", "description": {"zh": "BLE 会话 ID", "en": "BLE session ID"}, "type": "string", "required": True},
+# {"name": "service_uuid", "description": {"zh": "Service UUID", "en": "Service UUID"}, "type": "string", "required": True},
+# {"name": "characteristic_uuid", "description": {"zh": "Characteristic UUID", "en": "Characteristic UUID"}, "type": "string", "required": True},
+# {"name": "enable", "description": {"zh": "是否订阅。", "en": "Whether to subscribe."}, "type": "boolean", "required": False}
+# ]},
+# {"name": "bluetooth_ble_read_notifications", "description": {"zh": "读取已收到的 BLE 通知。", "en": "Read received BLE notifications."}, "parameters": [
+# {"name": "session_id", "description": {"zh": "BLE 会话 ID", "en": "BLE session ID"}, "type": "string", "required": True},
+# {"name": "limit", "description": {"zh": "读取条数。", "en": "Number of notifications to read."}, "type": "number", "required": False}
+# ]},
+# {"name": "get_device_info", "description": {"zh": "获取详细的设备信息,包括型号、操作系统版本、内存、存储、网络状态等。", "en": "Get detailed device information, including model, OS version, memory, storage, network status, etc."}, "parameters": []}
+# ]
+# }
+
+import json
+import re
+import time
+import math
+from urllib.parse import quote, unquote
+
+Error = Exception
+
+# 注意:以下工具大多依赖 Android 系统 API(Tools.System.*)。
+# 在非 Android 平台上运行时,运行时会返回"不支持"提示。
+
+
+async def get_system_setting(params):
+ result = await Tools.System.getSetting(params.get("setting"), params.get("namespace") or "system")
+ return {"success": True, "message": "成功获取系统设置", "data": result}
+
+
+async def modify_system_setting(params):
+ result = await Tools.System.setSetting(params.get("setting"), params.get("value"), params.get("namespace") or "system")
+ success = result and result.value == params.get("value")
+ return {"success": success, "message": "成功修改系统设置" if success else "修改系统设置失败", "data": result}
+
+
+async def install_app(params):
+ result = await Tools.System.installApp(params.get("path"))
+ return {"success": result.success, "message": "应用安装成功" if result.success else "应用安装失败", "data": result}
+
+
+async def uninstall_app(params):
+ result = await Tools.System.uninstallApp(params.get("package_name"))
+ return {"success": result.success, "message": "应用卸载成功" if result.success else "应用卸载失败", "data": result}
+
+
+async def list_installed_apps(params):
+ result = await Tools.System.listApps(params.get("include_system_apps") or False)
+ return {"success": True, "message": "成功获取应用列表", "data": result}
+
+
+async def start_app(params):
+ result = await Tools.System.startApp(params.get("package_name"), params.get("activity"))
+ return {"success": result.success, "message": "应用启动成功" if result.success else "应用启动失败", "data": result}
+
+
+async def stop_app(params):
+ result = await Tools.System.stopApp(params.get("package_name"))
+ return {"success": result.success, "message": "应用停止成功" if result.success else "应用停止失败", "data": result}
+
+
+async def send_broadcast(params):
+ result = await Tools.System.sendBroadcast({
+ "action": params.get("action"),
+ "uri": params.get("uri"),
+ "package": params.get("package_name"),
+ "component": params.get("component"),
+ "extras": params.get("extras"),
+ "extra_key": params.get("extra_key"),
+ "extra_value": params.get("extra_value"),
+ "extra_key2": params.get("extra_key2"),
+ "extra_value2": params.get("extra_value2"),
+ })
+ return {"success": True, "message": "广播发送成功", "data": result}
+
+
+async def execute_intent(params):
+ flags = params.get("flags")
+ if isinstance(flags, list):
+ flags = json.dumps(flags)
+ result = await Tools.System.intent({
+ "action": params.get("action"),
+ "uri": params.get("uri"),
+ "package": params.get("package_name"),
+ "component": params.get("component"),
+ "flags": flags,
+ "extras": params.get("extras"),
+ "type": params.get("type") or "activity",
+ })
+ return {"success": True, "message": "Intent 执行成功", "data": result}
+
+
+async def get_notifications(params):
+ result = await Tools.System.getNotifications(params.get("limit") or 10, params.get("include_ongoing") or False)
+ return {"success": True, "message": "成功获取通知", "data": result}
+
+
+async def get_app_usage_time(params):
+ result = await Tools.System.getAppUsageTime({
+ "packageName": params.get("package_name"),
+ "sinceHours": params.get("since_hours") or 24,
+ "limit": params.get("limit") or 10,
+ "includeSystemApps": params.get("include_system_apps") or False,
+ })
+ return {"success": True, "message": "成功获取应用使用时长", "data": result}
+
+
+async def get_device_location(params):
+ result = await Tools.System.getLocation(params.get("high_accuracy") or False, params.get("timeout") or 10)
+ return {"success": True, "message": "成功获取位置信息", "data": result}
+
+
+async def request_bluetooth_permission(params):
+ result = await Tools.System.bluetooth.requestPermission()
+ return {"success": True, "message": "成功请求蓝牙权限", "data": result}
+
+
+async def get_bluetooth_state(params):
+ result = await Tools.System.bluetooth.getState()
+ return {"success": True, "message": "成功获取蓝牙状态", "data": result}
+
+
+async def request_enable_bluetooth(params):
+ result = await Tools.System.bluetooth.requestEnable()
+ return {"success": True, "message": "已打开蓝牙开启请求", "data": result}
+
+
+async def list_bluetooth_bonded_devices(params):
+ result = await Tools.System.bluetooth.listBondedDevices()
+ return {"success": True, "message": "成功获取已配对蓝牙设备", "data": result}
+
+
+async def scan_bluetooth_devices(params):
+ result = await Tools.System.bluetooth.scan({
+ "durationMs": params.get("duration_ms"),
+ "includeBle": params.get("include_ble"),
+ })
+ return {"success": True, "message": "成功扫描蓝牙设备", "data": result}
+
+
+async def bluetooth_connect(params):
+ result = await Tools.System.bluetooth.connect({
+ "address": params.get("address"),
+ "uuid": params.get("uuid"),
+ })
+ return {"success": True, "message": "成功连接蓝牙设备", "data": result}
+
+
+async def bluetooth_listen(params):
+ result = await Tools.System.bluetooth.listen({
+ "name": params.get("name"),
+ "uuid": params.get("uuid"),
+ })
+ return {"success": True, "message": "成功创建蓝牙监听", "data": result}
+
+
+async def bluetooth_accept(params):
+ result = await Tools.System.bluetooth.accept(params.get("listener_session_id"), params.get("timeout_ms"))
+ return {"success": True, "message": "成功接受蓝牙连接", "data": result}
+
+
+async def bluetooth_send(params):
+ result = await Tools.System.bluetooth.send(params.get("session_id"), {
+ "text": params.get("text"),
+ "dataBase64": params.get("data_base64"),
+ })
+ return {"success": True, "message": "成功发送蓝牙数据", "data": result}
+
+
+async def bluetooth_read(params):
+ result = await Tools.System.bluetooth.read(params.get("session_id"), {
+ "maxBytes": params.get("max_bytes"),
+ "timeoutMs": params.get("timeout_ms"),
+ })
+ return {"success": True, "message": "成功读取蓝牙数据", "data": result}
+
+
+async def bluetooth_send_and_read(params):
+ result = await Tools.System.bluetooth.sendAndRead(params.get("session_id"), {
+ "text": params.get("text"),
+ "dataBase64": params.get("data_base64"),
+ "maxBytes": params.get("max_bytes"),
+ "timeoutMs": params.get("timeout_ms"),
+ })
+ return {"success": True, "message": "成功发送并读取蓝牙数据", "data": result}
+
+
+async def bluetooth_close(params):
+ result = await Tools.System.bluetooth.close(params.get("session_id"))
+ return {"success": True, "message": "成功关闭蓝牙会话", "data": result}
+
+
+async def bluetooth_ble_connect(params):
+ result = await Tools.System.bluetooth.ble.connect({
+ "address": params.get("address"),
+ "autoConnect": params.get("auto_connect"),
+ })
+ return {"success": True, "message": "成功连接 BLE 设备", "data": result}
+
+
+async def bluetooth_ble_discover_services(params):
+ result = await Tools.System.bluetooth.ble.discoverServices(params.get("session_id"), params.get("timeout_ms"))
+ return {"success": True, "message": "成功发现 BLE 服务", "data": result}
+
+
+async def bluetooth_ble_read_characteristic(params):
+ result = await Tools.System.bluetooth.ble.readCharacteristic(params.get("session_id"), {
+ "serviceUuid": params.get("service_uuid"),
+ "characteristicUuid": params.get("characteristic_uuid"),
+ "timeoutMs": params.get("timeout_ms"),
+ })
+ return {"success": True, "message": "成功读取 BLE characteristic", "data": result}
+
+
+async def bluetooth_ble_write_characteristic(params):
+ result = await Tools.System.bluetooth.ble.writeCharacteristic(params.get("session_id"), {
+ "serviceUuid": params.get("service_uuid"),
+ "characteristicUuid": params.get("characteristic_uuid"),
+ "text": params.get("text"),
+ "dataBase64": params.get("data_base64"),
+ })
+ return {"success": True, "message": "成功写入 BLE characteristic", "data": result}
+
+
+async def bluetooth_ble_write_and_read_characteristic(params):
+ result = await Tools.System.bluetooth.ble.writeAndReadCharacteristic(params.get("session_id"), {
+ "writeServiceUuid": params.get("write_service_uuid"),
+ "writeCharacteristicUuid": params.get("write_characteristic_uuid"),
+ "readServiceUuid": params.get("read_service_uuid"),
+ "readCharacteristicUuid": params.get("read_characteristic_uuid"),
+ "text": params.get("text"),
+ "dataBase64": params.get("data_base64"),
+ "timeoutMs": params.get("timeout_ms"),
+ })
+ return {"success": True, "message": "成功写入并读取 BLE characteristic", "data": result}
+
+
+async def bluetooth_ble_subscribe_characteristic(params):
+ result = await Tools.System.bluetooth.ble.subscribe(params.get("session_id"), {
+ "serviceUuid": params.get("service_uuid"),
+ "characteristicUuid": params.get("characteristic_uuid"),
+ "enable": params.get("enable"),
+ })
+ return {"success": True, "message": "成功更新 BLE 订阅", "data": result}
+
+
+async def bluetooth_ble_read_notifications(params):
+ result = await Tools.System.bluetooth.ble.readNotifications(params.get("session_id"), params.get("limit"))
+ return {"success": True, "message": "成功读取 BLE 通知", "data": result}
+
+
+async def get_device_info(params):
+ result = await Tools.System.getDeviceInfo()
+ return {"success": True, "message": "成功获取设备信息", "data": result}
+
+
+async def wrapToolExecution(func, params):
+ try:
+ result = await func(params)
+ complete(result)
+ except Exception as error:
+ console.error(f"Tool {func.__name__} failed unexpectedly", error)
+ complete({
+ "success": False,
+ "message": f"工具执行时发生意外错误: {str(error)}",
+ })
+
+
+async def main():
+ console.log("=== System Tools 全面测试开始 ===\n")
+ results = []
+
+ try:
+ # 1. 测试 get_device_info
+ console.log("1. 测试 get_device_info...")
+ try:
+ deviceInfoResult = await get_device_info({})
+ results.append({"tool": "get_device_info", "result": deviceInfoResult})
+ console.log("✓ get_device_info 测试完成")
+ if deviceInfoResult.get("data"):
+ console.log(f" 设备信息: {json.dumps(deviceInfoResult['data'], ensure_ascii=False)[:100]}...")
+ console.log()
+ except Exception as error:
+ console.log(f"⚠ get_device_info 测试失败: {str(error)} \n")
+ results.append({"tool": "get_device_info", "result": {"success": False, "message": str(error)}})
+
+ # 2. 测试 get_notifications
+ console.log("2. 测试 get_notifications...")
+ try:
+ notificationsResult = await get_notifications({"limit": 5, "include_ongoing": False})
+ results.append({"tool": "get_notifications", "result": notificationsResult})
+ console.log("✓ get_notifications 测试完成")
+ if notificationsResult.get("data"):
+ console.log(f" 获取到 {len(notificationsResult['data']) or 0} 条通知")
+ console.log()
+ except Exception as error:
+ console.log(f"⚠ get_notifications 测试失败: {str(error)} \n")
+ results.append({"tool": "get_notifications", "result": {"success": False, "message": str(error)}})
+
+ # 3. 测试 get_app_usage_time
+ console.log("3. 测试 get_app_usage_time...")
+ try:
+ usageResult = await get_app_usage_time({"since_hours": 24, "limit": 5, "include_system_apps": False})
+ results.append({"tool": "get_app_usage_time", "result": usageResult})
+ console.log("✓ get_app_usage_time 测试完成")
+ if usageResult.get("data"):
+ entries = usageResult["data"].get("entries") if isinstance(usageResult["data"], dict) else None
+ console.log(f" 返回条目数: {len(entries) if entries else 0}")
+ console.log()
+ except Exception as error:
+ console.log(f"⚠ get_app_usage_time 测试失败(可能需要使用情况访问权限): {str(error)} \n")
+ results.append({"tool": "get_app_usage_time", "result": {"success": False, "message": str(error)}})
+
+ # 4. 测试 get_device_location
+ console.log("4. 测试 get_device_location...")
+ try:
+ locationResult = await get_device_location({"high_accuracy": False, "timeout": 5})
+ results.append({"tool": "get_device_location", "result": locationResult})
+ console.log("✓ get_device_location 测试完成")
+ if locationResult.get("data"):
+ console.log(f" 位置: {json.dumps(locationResult['data'], ensure_ascii=False)}")
+ console.log()
+ except Exception as error:
+ console.log(f"⚠ get_device_location 测试失败(可能需要位置权限): {str(error)} \n")
+ results.append({"tool": "get_device_location", "result": {"success": False, "message": str(error)}})
+
+ # 5. 测试 list_installed_apps
+ console.log("5. 测试 list_installed_apps...")
+ try:
+ appsResult = await list_installed_apps({"include_system_apps": False})
+ results.append({"tool": "list_installed_apps", "result": appsResult})
+ console.log("✓ list_installed_apps 测试完成")
+ if appsResult.get("data"):
+ console.log(f" 已安装应用数量: {len(appsResult['data']) or 0}")
+ console.log()
+ except Exception as error:
+ console.log(f"⚠ list_installed_apps 测试失败(可能需要用户授权): {str(error)} \n")
+ results.append({"tool": "list_installed_apps", "result": {"success": False, "message": str(error)}})
+
+ # 6. 测试 get_system_setting
+ console.log("6. 测试 get_system_setting...")
+ try:
+ settingResult = await get_system_setting({"setting": "screen_brightness", "namespace": "system"})
+ results.append({"tool": "get_system_setting", "result": settingResult})
+ console.log("✓ get_system_setting 测试完成")
+ if settingResult.get("data"):
+ d = settingResult["data"]
+ console.log(f" screen_brightness = {d.get('value') if isinstance(d, dict) else d}")
+ console.log()
+ except Exception as error:
+ console.log(f"⚠ get_system_setting 测试失败: {str(error)} \n")
+ results.append({"tool": "get_system_setting", "result": {"success": False, "message": str(error)}})
+
+ # 7-11. 其他需要用户授权或可能造成系统变更的工具,仅做说明不实际执行
+ console.log("7-11. 跳过破坏性/需要特殊权限的工具测试:")
+ console.log(" ⊘ modify_system_setting - 需要WRITE_SETTINGS权限,会修改系统设置")
+ console.log(" ⊘ install_app - 需要INSTALL_PACKAGES权限")
+ console.log(" ⊘ uninstall_app - 需要DELETE_PACKAGES权限")
+ console.log(" ⊘ start_app - 需要应用包名,可能会启动应用")
+ console.log(" ⊘ stop_app - 需要KILL_BACKGROUND_PROCESSES权限\n")
+
+ results.append({"tool": "modify_system_setting", "result": {"success": None, "message": "未测试(避免修改系统)"}})
+ results.append({"tool": "install_app", "result": {"success": None, "message": "未测试(需要APK文件)"}})
+ results.append({"tool": "uninstall_app", "result": {"success": None, "message": "未测试(避免卸载应用)"}})
+ results.append({"tool": "start_app", "result": {"success": None, "message": "未测试(避免启动应用)"}})
+ results.append({"tool": "stop_app", "result": {"success": None, "message": "未测试(避免停止应用)"}})
+
+ console.log("=== System Tools 测试完成 ===\n")
+ console.log("测试结果汇总:")
+ for i, r in enumerate(results):
+ status = "✓" if r["result"]["success"] is True else ("✗" if r["result"]["success"] is False else "⊘")
+ console.log(f"{i + 1}. {status} {r['tool']}: {r['result']['message']}")
+
+ successCount = len([r for r in results if r["result"]["success"] is True])
+ failCount = len([r for r in results if r["result"]["success"] is False])
+ skipCount = len([r for r in results if r["result"]["success"] is None])
+
+ console.log(f"\n总计: {successCount} 成功, {failCount} 失败, {skipCount} 跳过")
+
+ complete({
+ "success": True,
+ "message": "系统工具全面测试完成",
+ "data": {
+ "results": results,
+ "summary": {
+ "total": len(results),
+ "success": successCount,
+ "failed": failCount,
+ "skipped": skipCount,
+ },
+ },
+ })
+ except Exception as error:
+ console.error("测试过程中发生错误:", error)
+ complete({
+ "success": False,
+ "message": f"测试失败: {str(error)}",
+ "data": results,
+ })
+
+
+def _make_wrapper(func):
+ async def wrapper(params):
+ await wrapToolExecution(func, params)
+ return wrapper
+
+
+exports.get_system_setting = _make_wrapper(get_system_setting)
+exports.modify_system_setting = _make_wrapper(modify_system_setting)
+exports.install_app = _make_wrapper(install_app)
+exports.uninstall_app = _make_wrapper(uninstall_app)
+exports.list_installed_apps = _make_wrapper(list_installed_apps)
+exports.start_app = _make_wrapper(start_app)
+exports.stop_app = _make_wrapper(stop_app)
+exports.send_broadcast = _make_wrapper(send_broadcast)
+exports.execute_intent = _make_wrapper(execute_intent)
+exports.get_notifications = _make_wrapper(get_notifications)
+exports.get_app_usage_time = _make_wrapper(get_app_usage_time)
+exports.get_device_location = _make_wrapper(get_device_location)
+exports.request_bluetooth_permission = _make_wrapper(request_bluetooth_permission)
+exports.get_bluetooth_state = _make_wrapper(get_bluetooth_state)
+exports.request_enable_bluetooth = _make_wrapper(request_enable_bluetooth)
+exports.list_bluetooth_bonded_devices = _make_wrapper(list_bluetooth_bonded_devices)
+exports.scan_bluetooth_devices = _make_wrapper(scan_bluetooth_devices)
+exports.bluetooth_connect = _make_wrapper(bluetooth_connect)
+exports.bluetooth_listen = _make_wrapper(bluetooth_listen)
+exports.bluetooth_accept = _make_wrapper(bluetooth_accept)
+exports.bluetooth_send = _make_wrapper(bluetooth_send)
+exports.bluetooth_read = _make_wrapper(bluetooth_read)
+exports.bluetooth_send_and_read = _make_wrapper(bluetooth_send_and_read)
+exports.bluetooth_close = _make_wrapper(bluetooth_close)
+exports.bluetooth_ble_connect = _make_wrapper(bluetooth_ble_connect)
+exports.bluetooth_ble_discover_services = _make_wrapper(bluetooth_ble_discover_services)
+exports.bluetooth_ble_read_characteristic = _make_wrapper(bluetooth_ble_read_characteristic)
+exports.bluetooth_ble_write_characteristic = _make_wrapper(bluetooth_ble_write_characteristic)
+exports.bluetooth_ble_write_and_read_characteristic = _make_wrapper(bluetooth_ble_write_and_read_characteristic)
+exports.bluetooth_ble_subscribe_characteristic = _make_wrapper(bluetooth_ble_subscribe_characteristic)
+exports.bluetooth_ble_read_notifications = _make_wrapper(bluetooth_ble_read_notifications)
+exports.get_device_info = _make_wrapper(get_device_info)
+exports.main = main
diff --git a/operit_py/tasker.py b/operit_py/tasker.py
new file mode 100644
index 0000000..72fd278
--- /dev/null
+++ b/operit_py/tasker.py
@@ -0,0 +1,59 @@
+# METADATA
+# {
+# "name": "tasker",
+#
+# "display_name": {
+# "zh": "Tasker 触发器",
+# "en": "Tasker Trigger"
+# },
+# "description": {
+# "zh": "集成 Tasker 插件事件触发工具,通过本包可向 Tasker 发送事件。",
+# "en": "Integration for triggering Tasker plugin events. This package lets you send events to Tasker."
+# },
+# "enabledByDefault": false,
+# "category": "Workflow",
+# "tools": [
+# {
+# "name": "trigger_tasker_event",
+# "description": {
+# "zh": "触发一个 Tasker 事件。使用 task_type 指定事件类型,可传 arg1..arg5 或 args_json。",
+# "en": "Trigger a Tasker event. Use task_type to specify the event type. You can pass arg1..arg5 or args_json."
+# },
+# "parameters": [
+# { "name": "task_type", "description": { "zh": "事件类型标识", "en": "Event type identifier" }, "type": "string", "required": true },
+# { "name": "arg1", "description": { "zh": "可选参数1", "en": "Optional argument 1" }, "type": "string", "required": false },
+# { "name": "arg2", "description": { "zh": "可选参数2", "en": "Optional argument 2" }, "type": "string", "required": false },
+# { "name": "arg3", "description": { "zh": "可选参数3", "en": "Optional argument 3" }, "type": "string", "required": false },
+# { "name": "arg4", "description": { "zh": "可选参数4", "en": "Optional argument 4" }, "type": "string", "required": false },
+# { "name": "arg5", "description": { "zh": "可选参数5", "en": "Optional argument 5" }, "type": "string", "required": false },
+# { "name": "args_json", "description": { "zh": "以JSON形式传递任意参数", "en": "Pass arbitrary parameters as a JSON string" }, "type": "string", "required": false }
+# ]
+# }
+# ]
+# }
+
+
+async def trigger_tasker_event(params):
+ data = await Tools.Tasker.triggerEvent(params)
+ return {
+ "success": True,
+ "message": "Tasker 事件已触发",
+ "data": data
+ }
+
+
+async def wrapToolExecution(func, params=None):
+ try:
+ result = await func(params if params is not None else {})
+ complete(result)
+ except Exception as error:
+ console.error(f"Tool {func.__name__} failed unexpectedly", error)
+ msg = str(getattr(error, "message", error))
+ complete({"success": False, "message": msg})
+
+
+async def trigger_tasker_event_wrapper(params=None):
+ await wrapToolExecution(trigger_tasker_event, params)
+
+
+exports.trigger_tasker_event = trigger_tasker_event_wrapper
diff --git a/operit_py/tavily.py b/operit_py/tavily.py
new file mode 100644
index 0000000..457754e
--- /dev/null
+++ b/operit_py/tavily.py
@@ -0,0 +1,199 @@
+# METADATA
+# {
+# "name": "tavily",
+#
+# "display_name": {
+# "zh": "Tavily 搜索",
+# "en": "Tavily Search"
+# },
+# "description": {
+# "zh": "使用Tavily API执行高级网络搜索、内容提取、网站爬取和站点地图生成。",
+# "en": "Use the Tavily API for advanced web search, content extraction, website crawling, and sitemap generation."
+# },
+# "env": [
+# "TAVILY_API_KEY"
+# ],
+# "category": "Search",
+# "tools": [
+# {
+# "name": "search",
+# "description": { "zh": "一个强大的网络搜索工具,使用Tavily的AI搜索引擎提供全面、实时的结果。", "en": "Powerful web search using Tavily's AI search engine for comprehensive, up-to-date results." },
+# "parameters": [
+# { "name": "query", "description": { "zh": "搜索查询", "en": "Search query" }, "type": "string", "required": true },
+# { "name": "search_depth", "description": { "zh": "搜索深度,可以是 'basic' 或 'advanced'", "en": "Search depth: 'basic' or 'advanced'" }, "type": "string", "required": false, "default": "basic" },
+# { "name": "topic", "description": { "zh": "搜索类别,可以是 'general' 或 'news'", "en": "Search topic: 'general' or 'news'" }, "type": "string", "required": false, "default": "general" },
+# { "name": "days", "description": { "zh": "包含在搜索结果中的过去天数", "en": "Number of past days to include in results" }, "type": "number", "required": false },
+# { "name": "max_results", "description": { "zh": "返回的最大搜索结果数", "en": "Maximum number of search results" }, "type": "number", "required": false, "default": 10 },
+# { "name": "include_images", "description": { "zh": "在响应中包含与查询相关的图像列表", "en": "Include a list of images related to the query in the response" }, "type": "boolean", "required": false, "default": false },
+# { "name": "include_raw_content", "description": { "zh": "包含每个搜索结果的已清理和解析的HTML内容", "en": "Include cleaned and parsed HTML content for each search result" }, "type": "boolean", "required": false, "default": false },
+# { "name": "include_domains", "description": { "zh": "要专门包含在搜索结果中的域名列表", "en": "Domain list to specifically include in results" }, "type": "array", "required": false, "default": [] },
+# { "name": "exclude_domains", "description": { "zh": "要专门排除的域名列表", "en": "Domain list to specifically exclude from results" }, "type": "array", "required": false, "default": [] }
+# ]
+# },
+# {
+# "name": "extract",
+# "description": { "zh": "一个强大的网页内容提取工具,从指定的URL检索和处理原始内容。", "en": "Extract web content from specified URLs and process the raw content." },
+# "parameters": [
+# { "name": "urls", "description": { "zh": "要提取内容的URL列表", "en": "List of URLs to extract content from" }, "type": "array", "required": true },
+# { "name": "extract_depth", "description": { "zh": "提取深度 - 'basic' 或 'advanced'", "en": "Extraction depth: 'basic' or 'advanced'" }, "type": "string", "required": false, "default": "basic" },
+# { "name": "include_images", "description": { "zh": "在响应中包含从URL中提取的图像列表", "en": "Include a list of images extracted from the URL(s)" }, "type": "boolean", "required": false, "default": false },
+# { "name": "format", "description": { "zh": "提取内容的格式,'markdown' 或 'text'", "en": "Output format: 'markdown' or 'text'" }, "type": "string", "required": false, "default": "markdown" }
+# ]
+# },
+# {
+# "name": "crawl",
+# "description": { "zh": "一个强大的网络爬虫,从指定的基URL开始结构化地爬取网站。", "en": "Structured website crawler starting from a base URL." },
+# "parameters": [
+# { "name": "url", "description": { "zh": "开始爬取的根URL", "en": "Root URL to start crawling from" }, "type": "string", "required": true },
+# { "name": "max_depth", "description": { "zh": "爬取的最大深度", "en": "Maximum crawl depth" }, "type": "number", "required": false, "default": 1 },
+# { "name": "max_breadth", "description": { "zh": "每层要跟随的最大链接数", "en": "Maximum number of links to follow per depth level" }, "type": "number", "required": false, "default": 20 },
+# { "name": "limit", "description": { "zh": "爬虫将处理的总链接数", "en": "Total number of links the crawler will process" }, "type": "number", "required": false, "default": 50 },
+# { "name": "instructions", "description": { "zh": "给爬虫的自然语言指令", "en": "Natural-language instructions for the crawler" }, "type": "string", "required": false },
+# { "name": "allow_external", "description": { "zh": "是否允许跟随外部域名的链接", "en": "Whether to allow following links to external domains" }, "type": "boolean", "required": false, "default": false },
+# { "name": "format", "description": { "zh": "提取内容的格式,'markdown' 或 'text'", "en": "Output format: 'markdown' or 'text'" }, "type": "string", "required": false, "default": "markdown" }
+# ]
+# },
+# {
+# "name": "map",
+# "description": { "zh": "一个强大的网站地图工具,创建网站URL的结构化地图。", "en": "Generate a structured sitemap of website URLs." },
+# "parameters": [
+# { "name": "url", "description": { "zh": "开始映射的根URL", "en": "Root URL to start mapping from" }, "type": "string", "required": true },
+# { "name": "max_depth", "description": { "zh": "映射的最大深度", "en": "Maximum mapping depth" }, "type": "number", "required": false, "default": 1 },
+# { "name": "max_breadth", "description": { "zh": "每层要跟随的最大链接数", "en": "Maximum number of links to follow per depth level" }, "type": "number", "required": false, "default": 20 },
+# { "name": "limit", "description": { "zh": "爬虫将处理的总链接数", "en": "Total number of links the crawler will process" }, "type": "number", "required": false, "default": 50 }
+# ]
+# }
+# ]
+# }
+
+import json
+import traceback
+
+client = OkHttp.newClient()
+BASE_URLS = {
+ "search": "https://api.tavily.com/search",
+ "extract": "https://api.tavily.com/extract",
+ "crawl": "https://api.tavily.com/crawl",
+ "map": "https://api.tavily.com/map"
+}
+
+
+async def makeTavilyRequest(endpoint, params):
+ apiKey = getEnv("TAVILY_API_KEY")
+ if not apiKey:
+ raise Exception("Tavily API key is not set. Please configure it in the environment variables.")
+
+ requestBody = {**params}
+
+ headers = {
+ "accept": "application/json",
+ "content-type": "application/json",
+ "Authorization": f"Bearer {apiKey}"
+ }
+
+ try:
+ request = client.newRequest()
+ request = request.url(endpoint)
+ request = request.method("POST")
+ request = request.headers(headers)
+ request = request.body(json.dumps(requestBody), "json")
+
+ response = await request.build().execute()
+
+ if not response.isSuccessful():
+ raise Exception(f"Tavily API Error: {response.statusCode} - {response.content}")
+ return json.loads(response.content)
+ except Exception as error:
+ console.error(f"Tavily request failed: {error}")
+ raise error
+
+
+def formatSearchResults(response):
+ output = []
+ if response.get("answer"):
+ output.append(f"Answer: {response['answer']}")
+ output.append("Detailed Results:")
+ for result in response.get("results", []):
+ output.append(f"\nTitle: {result.get('title')}")
+ output.append(f"URL: {result.get('url')}")
+ output.append(f"Content: {result.get('content')}")
+ if result.get("raw_content"):
+ output.append(f"Raw Content: {result['raw_content']}")
+ return "\n".join(output)
+
+
+def formatCrawlResults(response):
+ output = []
+ output.append("Crawl Results:")
+ output.append(f"Base URL: {response.get('base_url')}")
+ output.append("\nCrawled Pages:")
+ for index, page in enumerate(response.get("results", [])):
+ output.append(f"\n[{index + 1}] URL: {page.get('url')}")
+ if page.get("raw_content"):
+ raw = page["raw_content"]
+ contentPreview = (raw[:200] + "...") if len(raw) > 200 else raw
+ output.append(f"Content: {contentPreview}")
+ return "\n".join(output)
+
+
+def formatMapResults(response):
+ output = []
+ output.append("Site Map Results:")
+ output.append(f"Base URL: {response.get('base_url')}")
+ output.append("\nMapped Pages:")
+ for index, page in enumerate(response.get("results", [])):
+ output.append(f"\n[{index + 1}] URL: {page}")
+ return "\n".join(output)
+
+
+async def search(params):
+ response = await makeTavilyRequest(BASE_URLS["search"], params)
+ return formatSearchResults(response)
+
+
+async def extract(params):
+ response = await makeTavilyRequest(BASE_URLS["extract"], params)
+ # Extract uses the same format as search
+ return formatSearchResults(response)
+
+
+async def crawl(params):
+ response = await makeTavilyRequest(BASE_URLS["crawl"], params)
+ return formatCrawlResults(response)
+
+
+async def map(params):
+ response = await makeTavilyRequest(BASE_URLS["map"], params)
+ return formatMapResults(response)
+
+
+async def wrap(func, params, successMessage, failMessage):
+ try:
+ result = await func(params)
+ complete({"success": True, "message": successMessage, "data": result})
+ except Exception as error:
+ console.error(f"Function {func.__name__} failed! Error: {error}")
+ msg = str(getattr(error, "message", error))
+ complete({"success": False, "message": f"{failMessage}: {msg}", "error_stack": traceback.format_exc()})
+
+
+async def search_wrapper(p):
+ await wrap(search, p, "搜索成功", "搜索失败")
+
+
+async def extract_wrapper(p):
+ await wrap(extract, p, "提取成功", "提取失败")
+
+
+async def crawl_wrapper(p):
+ await wrap(crawl, p, "爬取成功", "爬取失败")
+
+
+async def map_wrapper(p):
+ await wrap(map, p, "映射成功", "映射失败")
+
+
+exports.search = search_wrapper
+exports.extract = extract_wrapper
+exports.crawl = crawl_wrapper
+exports.map = map_wrapper
diff --git a/operit_py/time.py b/operit_py/time.py
new file mode 100644
index 0000000..550dd19
--- /dev/null
+++ b/operit_py/time.py
@@ -0,0 +1,85 @@
+# METADATA
+# {
+# name: time
+#
+# display_name: {
+# zh: "时间"
+# en: "Time"
+# }
+# description: {
+# zh: "提供时间相关功能。实际上,激活本包的同时已经能够获取时间了。"
+# en: "Provides time-related utilities. In practice, current time is already available once this package is enabled."
+# }
+# enabledByDefault: true
+# category: "Utility"
+# tools: [
+# {
+# name: get_time
+# description: {
+# zh: "获取当前时间。当使用此包时,AI已经自动获取了当前的时间信息。"
+# en: "Get the current time. When using this package, the AI may already have the current time context."
+# }
+# parameters: []
+# },
+# {
+# name: format_time
+# description: {
+# zh: "格式化时间。提供各种时间格式化选项。"
+# en: "Format time. Provides various time formatting options."
+# }
+# parameters: []
+# }
+# ]
+# }
+
+import time
+import datetime
+
+
+async def get_time():
+ now = datetime.datetime.now()
+
+ return {
+ "timestamp": int(time.time() * 1000),
+ "iso": now.isoformat(),
+ "local": now.strftime("%Y-%m-%d %H:%M:%S"),
+ "date": {
+ "year": now.year,
+ "month": now.month,
+ "day": now.day,
+ "weekday": now.strftime("%A")
+ },
+ "time": {
+ "hours": now.hour,
+ "minutes": now.minute,
+ "seconds": now.second
+ }
+ }
+
+
+async def format_time():
+ now = datetime.datetime.now()
+
+ def pad(n):
+ return str(n).zfill(2)
+
+ hours = now.hour
+ minutes = now.minute
+ seconds = now.second
+
+ time24h = f"{pad(hours)}:{pad(minutes)}:{pad(seconds)}"
+
+ suffix = "PM" if hours >= 12 else "AM"
+ hour12 = 12 if hours % 12 == 0 else hours % 12
+ time12h = f"{pad(hour12)}:{pad(minutes)}:{pad(seconds)} {suffix}"
+
+ return {
+ "iso": now.isoformat(),
+ "date": f"{now.year}-{pad(now.month)}-{pad(now.day)}",
+ "time24h": time24h,
+ "time12h": time12h
+ }
+
+
+exports.get_time = get_time
+exports.format_time = format_time
diff --git a/operit_py/various_search.py b/operit_py/various_search.py
new file mode 100644
index 0000000..aeb4a47
--- /dev/null
+++ b/operit_py/various_search.py
@@ -0,0 +1,750 @@
+# METADATA
+# {
+# "name": "various_search",
+# "display_name": {"zh": "多平台搜索", "en": "Multi-Platform Search"},
+# "category": "Search",
+# "description": {"zh": "提供多平台搜索功能(含图片搜索),支持从必应、百度、搜狗、夸克等平台获取搜索结果。", "en": "Multi-platform search tools (including image search) that fetch results from Bing, Baidu, Sogou, Quark, and more."},
+# "enabledByDefault": True,
+# "tools": [
+# {"name": "search_bing", "description": {"zh": "使用必应搜索引擎进行搜索", "en": "Search using the Bing search engine."}, "parameters": [
+# {"name": "query", "description": {"zh": "搜索查询关键词", "en": "Search query keywords."}, "type": "string", "required": True},
+# {"name": "includeLinks", "description": {"zh": "是否在结果中包含可点击的链接列表,默认为false。", "en": "Whether to include a clickable link list in results (default: false)."}, "type": "boolean", "required": False}
+# ]},
+# {"name": "search_baidu", "description": {"zh": "使用百度搜索引擎进行搜索", "en": "Search using the Baidu search engine."}, "parameters": [
+# {"name": "query", "description": {"zh": "搜索查询关键词", "en": "Search query keywords."}, "type": "string", "required": True},
+# {"name": "page", "description": {"zh": "搜索结果页码,默认为1", "en": "Result page number (default: 1)."}, "type": "string", "required": False},
+# {"name": "includeLinks", "description": {"zh": "是否在结果中包含可点击的链接列表,默认为false。", "en": "Whether to include a clickable link list in results (default: false)."}, "type": "boolean", "required": False}
+# ]},
+# {"name": "search_sogou", "description": {"zh": "使用搜狗搜索引擎进行搜索", "en": "Search using the Sogou search engine."}, "parameters": [
+# {"name": "query", "description": {"zh": "搜索查询关键词", "en": "Search query keywords."}, "type": "string", "required": True},
+# {"name": "page", "description": {"zh": "搜索结果页码,默认为1", "en": "Result page number (default: 1)."}, "type": "string", "required": False},
+# {"name": "includeLinks", "description": {"zh": "是否在结果中包含可点击的链接列表,默认为false。", "en": "Whether to include a clickable link list in results (default: false)."}, "type": "boolean", "required": False}
+# ]},
+# {"name": "search_quark", "description": {"zh": "使用夸克搜索引擎进行搜索", "en": "Search using the Quark search engine."}, "parameters": [
+# {"name": "query", "description": {"zh": "搜索查询关键词", "en": "Search query keywords."}, "type": "string", "required": True},
+# {"name": "page", "description": {"zh": "搜索结果页码,默认为1", "en": "Result page number (default: 1)."}, "type": "string", "required": False},
+# {"name": "includeLinks", "description": {"zh": "是否在结果中包含可点击的链接列表,默认为false。", "en": "Whether to include a clickable link list in results (default: false)."}, "type": "boolean", "required": False}
+# ]},
+# {"name": "combined_search", "description": {"zh": "在多个平台同时执行搜索。建议用户要求搜索的时候默认使用这个工具。", "en": "Run searches across multiple platforms. Use this tool by default when the user asks to search."}, "parameters": [
+# {"name": "query", "description": {"zh": "搜索查询关键词", "en": "Search query keywords."}, "type": "string", "required": True},
+# {"name": "platforms", "description": {"zh": "搜索平台列表字符串,可选值包括\"bing\",\"baidu\",\"sogou\",\"quark\",多个平台用逗号分隔", "en": "Comma-separated platform list."}, "type": "string", "required": True},
+# {"name": "includeLinks", "description": {"zh": "是否在结果中包含可点击的链接列表,默认为false。", "en": "Whether to include a clickable link list in results (default: false)."}, "type": "boolean", "required": False}
+# ]},
+# {"name": "search", "description": {"zh": "兼容工具名:等同于 combined_search。", "en": "Compatibility alias: equivalent to combined_search."}, "parameters": [
+# {"name": "query", "description": {"zh": "搜索查询关键词", "en": "Search query keywords."}, "type": "string", "required": True},
+# {"name": "platforms", "description": {"zh": "可选平台列表,默认 bing,baidu,sogou,quark", "en": "Optional platform list, default bing,baidu,sogou,quark."}, "type": "string", "required": False},
+# {"name": "includeLinks", "description": {"zh": "是否返回链接列表,默认 false", "en": "Whether to include links in result, default false."}, "type": "boolean", "required": False}
+# ]},
+# {"name": "search_web", "description": {"zh": "兼容工具名:网页搜索别名。", "en": "Compatibility alias for web search."}, "parameters": [
+# {"name": "query", "description": {"zh": "搜索查询关键词", "en": "Search query keywords."}, "type": "string", "required": True},
+# {"name": "platforms", "description": {"zh": "可选平台列表,默认 bing,baidu,sogou,quark", "en": "Optional platform list."}, "type": "string", "required": False},
+# {"name": "includeLinks", "description": {"zh": "是否返回链接列表,默认 false", "en": "Whether to include links in result, default false."}, "type": "boolean", "required": False}
+# ]},
+# {"name": "search_bing_images", "description": {"zh": "使用必应图片搜索引擎进行图片搜索。", "en": "Search images using Bing Images."}, "parameters": [{"name": "query", "description": {"zh": "搜索关键词", "en": "Search query keywords."}, "type": "string", "required": True}]},
+# {"name": "search_wikimedia_images", "description": {"zh": "使用 Wikimedia Commons 进行图片搜索。", "en": "Search images using Wikimedia Commons."}, "parameters": [{"name": "query", "description": {"zh": "搜索关键词", "en": "Search query keywords."}, "type": "string", "required": True}]},
+# {"name": "search_duckduckgo_images", "description": {"zh": "使用 DuckDuckGo Images 进行图片搜索。", "en": "Search images using DuckDuckGo Images."}, "parameters": [{"name": "query", "description": {"zh": "搜索关键词", "en": "Search query keywords."}, "type": "string", "required": True}]},
+# {"name": "search_ecosia_images", "description": {"zh": "使用 Ecosia Images 进行图片搜索。", "en": "Search images using Ecosia Images."}, "parameters": [{"name": "query", "description": {"zh": "搜索关键词", "en": "Search query keywords."}, "type": "string", "required": True}]},
+# {"name": "search_pexels_images", "description": {"zh": "使用 Pexels 进行图片搜索。", "en": "Search images using Pexels."}, "parameters": [{"name": "query", "description": {"zh": "搜索关键词", "en": "Search query keywords."}, "type": "string", "required": True}]},
+# {"name": "search_pixabay_images", "description": {"zh": "使用 Pixabay 进行图片搜索。", "en": "Search images using Pixabay."}, "parameters": [{"name": "query", "description": {"zh": "搜索关键词", "en": "Search query keywords."}, "type": "string", "required": True}]}
+# ]
+# }
+
+import json
+import re
+import asyncio
+from urllib.parse import quote, unquote
+
+Error = Exception
+
+
+def normalizeText(input_):
+ if not input_:
+ return ""
+ return re.sub(r'[\s\-_.,;:!?()\[\]{}"\'`~@#$%^&*+=|\\/<>]+', '', str(input_).lower())
+
+
+OUTBOUND_PATH_HINTS = ["/link", "/url", "/redirect", "/jump", "/out", "/outlink", "/ulink", "/aladdin.php"]
+OUTBOUND_QUERY_KEYS = ["url", "u", "target", "targeturl", "target_url", "to", "dest", "destination", "redirect", "redirect_url"]
+MULTI_PART_TLDS = ["ac.uk", "co.jp", "co.uk", "com.au", "com.cn", "com.hk", "gov.cn", "net.au", "net.cn", "org.au", "org.cn", "org.uk"]
+
+
+def normalizePath(pathname):
+ normalized = re.sub(r'/+$', '', str(pathname or ""))
+ return normalized or "/"
+
+
+def parseUrlParts(rawUrl, baseUrl):
+ rawText = str(rawUrl or "").strip()
+ if not rawText:
+ return None
+ absoluteText = rawText
+ if absoluteText.startswith("//"):
+ absoluteText = f"https:{absoluteText}"
+ elif not re.match(r'^[a-z]+://', absoluteText, re.IGNORECASE):
+ if not absoluteText.startswith("/"):
+ return None
+ base = parseUrlParts(baseUrl, "") if baseUrl else None
+ if not base:
+ return None
+ absoluteText = f"{base['protocol']}//{base['host']}{absoluteText}"
+ match = re.match(r'^(https?)://([^/?#]+)([^?#]*)(\?[^#]*)?(?:#.*)?$', absoluteText, re.IGNORECASE)
+ if not match:
+ return None
+ protocol = f"{match.group(1).lower()}:"
+ host = match.group(2).lower()
+ hostname = re.sub(r':\d+$', '', host)
+ pathname = normalizePath(match.group(3) or "/")
+ search = match.group(4) or ""
+ return {
+ "href": f"{protocol}//{host}{pathname}{search}",
+ "protocol": protocol,
+ "host": host,
+ "hostname": hostname,
+ "pathname": pathname,
+ "search": search,
+ }
+
+
+def normalizeUrl(rawUrl, baseUrl):
+ parsed = parseUrlParts(rawUrl, baseUrl)
+ return parsed["href"] if parsed else ""
+
+
+def parseSearchEntries(searchText):
+ search = re.sub(r'^\?', '', str(searchText or ""))
+ if not search:
+ return []
+ result = []
+ for item in search.split("&"):
+ item = item.strip()
+ if not item:
+ continue
+ equalIndex = item.find("=")
+ if equalIndex == -1:
+ result.append([item, ""])
+ else:
+ result.append([item[:equalIndex], item[equalIndex + 1:]])
+ return result
+
+
+def getSiteDomain(hostname):
+ parts = [p for p in str(hostname or "").lower().split(".") if p]
+ if len(parts) <= 2:
+ return str(hostname or "").lower()
+ lastTwo = ".".join(parts[-2:])
+ if lastTwo in MULTI_PART_TLDS and len(parts) >= 3:
+ return ".".join(parts[-3:])
+ return lastTwo
+
+
+def countPathSegments(pathname):
+ return len([s for s in normalizePath(pathname).split("/") if s])
+
+
+def getSortedSearch(searchText):
+ entries = sorted(parseSearchEntries(searchText), key=lambda x: (x[0], x[1]))
+ return "&".join([f"{k}={v}" for k, v in entries])
+
+
+def toComparableUrl(rawUrl):
+ if not rawUrl:
+ return ""
+ parsed = parseUrlParts(rawUrl, "")
+ if not parsed:
+ return str(rawUrl).strip()
+ search = getSortedSearch(parsed["search"])
+ return f"{parsed['protocol']}//{parsed['host']}{parsed['pathname']}" + (f"?{search}" if search else "")
+
+
+def getLinkText(link):
+ text = link and (link.get("text") or link.get("title") or link.get("name"))
+ return re.sub(r'\s+', ' ', str(text)).strip() if text else ""
+
+
+def looksLikeUiText(text):
+ raw = re.sub(r'\s+', ' ', str(text or "")).strip()
+ normalized = normalizeText(text)
+ if not normalized:
+ return True
+ if re.match(r'^\d+$', normalized):
+ return True
+ if not re.search(r'[a-z0-9\u4e00-\u9fa5]', raw, re.IGNORECASE):
+ return True
+ if len(normalized) == 1:
+ return True
+ if len(normalized) == 2 and re.match(r'^[a-z0-9]+$', normalized, re.IGNORECASE):
+ return True
+ return False
+
+
+def looksLikeSearchLandingUrl(rawUrl, sourceUrl):
+ if not rawUrl or not sourceUrl:
+ return False
+ candidate = parseUrlParts(rawUrl, sourceUrl)
+ source = parseUrlParts(sourceUrl, "")
+ if not candidate or not source:
+ return False
+ if toComparableUrl(candidate["href"]) == toComparableUrl(source["href"]):
+ return True
+ if candidate["host"] != source["host"]:
+ return False
+ candidateSearch = getSortedSearch(candidate["search"])
+ sourceSearch = getSortedSearch(source["search"])
+ if candidate["pathname"] == "/" and not candidateSearch:
+ return True
+ return candidate["pathname"] == source["pathname"] and (not candidateSearch or candidateSearch == sourceSearch)
+
+
+def looksLikeOutboundWrapper(rawUrl, sourceUrl):
+ candidate = parseUrlParts(rawUrl, sourceUrl)
+ source = parseUrlParts(sourceUrl, "")
+ if not candidate or not source or candidate["host"] != source["host"]:
+ return False
+ path = candidate["pathname"].lower()
+ if any(path == hint or path.endswith(hint) for hint in OUTBOUND_PATH_HINTS):
+ return True
+ search = re.sub(r'^\?', '', candidate["search"])
+ return any(re.search(r'(?:^|&)' + key + r'(?:=|&|$)', search, re.IGNORECASE) for key in OUTBOUND_QUERY_KEYS)
+
+
+def looksLikeSameSiteSectionUrl(rawUrl, sourceUrl):
+ candidate = parseUrlParts(rawUrl, sourceUrl)
+ source = parseUrlParts(sourceUrl, "")
+ if not candidate or not source:
+ return False
+ if candidate["host"] == source["host"]:
+ return False
+ if getSiteDomain(candidate["hostname"]) != getSiteDomain(source["hostname"]):
+ return False
+ if looksLikeOutboundWrapper(candidate["href"], source["href"]):
+ return False
+ return countPathSegments(candidate["pathname"]) <= 1
+
+
+def classifyLinkTarget(rawUrl, sourceUrl):
+ text = str(rawUrl or "").strip()
+ if not text:
+ return "invalid"
+ lowered = text.lower()
+ if lowered.startswith("javascript:") or lowered.startswith("#"):
+ return "invalid"
+ candidate = parseUrlParts(rawUrl, sourceUrl)
+ source = parseUrlParts(sourceUrl, "")
+ if not candidate or not source:
+ return "invalid"
+ if looksLikeSearchLandingUrl(candidate["href"], source["href"]):
+ return "landing"
+ if looksLikeOutboundWrapper(candidate["href"], source["href"]):
+ return "wrapper"
+ if candidate["host"] == source["host"]:
+ return "internal"
+ if looksLikeSameSiteSectionUrl(candidate["href"], source["href"]):
+ return "internal"
+ if getSiteDomain(candidate["hostname"]) == getSiteDomain(source["hostname"]):
+ return "internal"
+ return "external"
+
+
+def extractUrlsFromText(rawText):
+ if not rawText:
+ return []
+ text = str(rawText)
+ matches = re.findall(r'https?://[^\s)\]>"\']+', text)
+ unique = []
+ for url in matches:
+ if url not in unique:
+ unique.append(url)
+ return unique
+
+
+def extractBestUrlFromText(rawText, sourceUrl):
+ urls = extractUrlsFromText(rawText)
+ for item in urls:
+ if classifyLinkTarget(item, sourceUrl) in ("external", "wrapper"):
+ return item
+ return urls[0] if urls else ""
+
+
+def stripLeadingLinkDump(rawContent):
+ lines = re.split(r'\r?\n', str(rawContent or ""))
+ index = 0
+ while index < len(lines) and not lines[index].strip():
+ index += 1
+ dumpedLinkCount = 0
+ while index < len(lines) and re.match(r'^\[\d+\]\s', lines[index].strip()):
+ dumpedLinkCount += 1
+ index += 1
+ if dumpedLinkCount == 0:
+ return str(rawContent or "")
+ while index < len(lines) and not lines[index].strip():
+ index += 1
+ return "\n".join(lines[index:])
+
+
+def collectLinkCandidates(link, sourceUrl):
+ if not link:
+ link = {}
+ keys = ["realUrl", "real_url", "targetUrl", "target_url", "originUrl", "origin_url", "href", "url", "target", "rawUrl", "link"]
+ unique = []
+ for key in keys:
+ value = link.get(key)
+ normalized = normalizeUrl(str(value).strip() if value else "", sourceUrl)
+ if normalized and normalized not in unique:
+ unique.append(normalized)
+ return unique
+
+
+def pickBestLinkUrl(link, sourceUrl):
+ candidates = collectLinkCandidates(link, sourceUrl)
+ for item in candidates:
+ if classifyLinkTarget(item, sourceUrl) in ("external", "wrapper"):
+ return item
+ fromText = extractBestUrlFromText(getLinkText(link), sourceUrl)
+ if fromText:
+ return fromText
+ return candidates[0] if candidates else ""
+
+
+def scoreStructuralCandidate(text, url, sourceUrl, relativeIndex):
+ score = 0
+ if not text or looksLikeUiText(text):
+ score -= 6
+ else:
+ score += 2
+ linkType = classifyLinkTarget(url, sourceUrl)
+ if linkType == "external":
+ score += 5
+ elif linkType == "wrapper":
+ score += 4
+ elif linkType in ("landing", "internal"):
+ score -= 8
+ else:
+ score -= 4
+ score -= relativeIndex * 0.15
+ return score
+
+
+def findResultBlockStart(links, sourceUrl, scanLimit=48, windowSize=6, minCandidates=3):
+ upperBound = min(len(links), scanLimit)
+ for start in range(upperBound):
+ candidateCount = 0
+ hosts = []
+ for offset in range(windowSize):
+ if start + offset >= upperBound:
+ break
+ link = links[start + offset]
+ text = getLinkText(link)
+ url = pickBestLinkUrl(link, sourceUrl)
+ if not text or looksLikeUiText(text):
+ continue
+ linkType = classifyLinkTarget(url, sourceUrl)
+ if linkType not in ("external", "wrapper"):
+ continue
+ candidateCount += 1
+ parsed = parseUrlParts(url, sourceUrl)
+ host = parsed["hostname"] if parsed else ""
+ if host and host not in hosts:
+ hosts.append(host)
+ if candidateCount >= minCandidates and len(hosts) >= 2:
+ return start
+ return 0
+
+
+def isClusteredResultLink(links, sourceUrl, targetIndex, radius=3, minCandidates=3):
+ candidateCount = 0
+ hosts = []
+ start = max(0, targetIndex - radius)
+ end = min(len(links) - 1, targetIndex + radius)
+ for index in range(start, end + 1):
+ link = links[index]
+ text = getLinkText(link)
+ url = pickBestLinkUrl(link, sourceUrl)
+ if not text or looksLikeUiText(text):
+ continue
+ linkType = classifyLinkTarget(url, sourceUrl)
+ if linkType not in ("external", "wrapper"):
+ continue
+ candidateCount += 1
+ parsed = parseUrlParts(url, sourceUrl)
+ host = parsed["hostname"] if parsed else ""
+ if host and host not in hosts:
+ hosts.append(host)
+ return candidateCount >= minCandidates and len(hosts) >= 2
+
+
+def buildProbeIndexes(links, sourceUrl, maxCount=8, probeWindow=24):
+ resultStart = findResultBlockStart(links, sourceUrl)
+ result = []
+ for relativeIndex, link in enumerate(links[resultStart:resultStart + probeWindow]):
+ index = resultStart + relativeIndex
+ text = getLinkText(link)
+ url = pickBestLinkUrl(link, sourceUrl)
+ score = scoreStructuralCandidate(text, url, sourceUrl, relativeIndex)
+ if score > -4 and isClusteredResultLink(links, sourceUrl, index):
+ result.append(index)
+ if len(result) >= maxCount:
+ break
+ return result
+
+
+def chooseBestUrl(directUrl, resolvedUrl, sourceUrl):
+ candidates = [resolvedUrl, directUrl]
+ for candidate in candidates:
+ if candidate and classifyLinkTarget(candidate, sourceUrl) == "external":
+ return normalizeUrl(candidate, sourceUrl)
+ return ""
+
+
+def shouldKeepLink(links, index, text, url, sourceUrl):
+ if not url:
+ return False
+ if not text or looksLikeUiText(text):
+ return False
+ if classifyLinkTarget(url, sourceUrl) != "external":
+ return False
+ return isClusteredResultLink(links, sourceUrl, index)
+
+
+async def resolveLinkUrlsByVisitKey(response, sourceUrl, maxCount=8):
+ if not response or not response.get("visitKey") or not response.get("links") or not isinstance(response.get("links"), list):
+ return {}
+ resolved = {}
+ indexes = buildProbeIndexes(response["links"], sourceUrl, maxCount)
+
+ async def resolveOne(index):
+ try:
+ follow = await Tools.Net.visit({"visit_key": response["visitKey"], "link_number": index + 1})
+ followUrl = normalizeUrl(str(follow.get("url")), sourceUrl) if follow and follow.get("url") else ""
+ followContent = str(follow.get("content")) if follow and follow.get("content") else ""
+ contentUrl = extractBestUrlFromText(followContent, sourceUrl)
+ resolved[index] = {"url": chooseBestUrl("", followUrl, sourceUrl) or chooseBestUrl("", contentUrl, sourceUrl)}
+ except Exception as error:
+ console.error(f"[resolveLinkUrlsByVisitKey] link {index + 1} failed: {str(error)}")
+ resolved[index] = {"url": ""}
+
+ await asyncio.gather(*[resolveOne(index) for index in indexes])
+ return resolved
+
+
+async def buildLinkLines(response, sourceUrl, maxItems=20):
+ resultStart = findResultBlockStart(response["links"], sourceUrl)
+ resolvedByIndex = await resolveLinkUrlsByVisitKey(response, sourceUrl)
+ lines = []
+ seen = set()
+ for index in range(resultStart, len(response["links"])):
+ link = response["links"][index]
+ text = getLinkText(link)
+ directUrl = pickBestLinkUrl(link, sourceUrl)
+ resolved = resolvedByIndex.get(index)
+ bestUrl = chooseBestUrl(directUrl, resolved["url"] if resolved and resolved.get("url") else "", sourceUrl)
+ if not shouldKeepLink(response["links"], index, text, bestUrl, sourceUrl):
+ continue
+ key = toComparableUrl(bestUrl)
+ if not key or key in seen:
+ continue
+ seen.add(key)
+ lines.append(f"[{index + 1}] {text} - {bestUrl}")
+ if len(lines) >= maxItems:
+ break
+ return lines
+
+
+async def performSearch(platform, url, includeLinks=False):
+ try:
+ response = await Tools.Net.visit({"url": url})
+ if not response:
+ raise Error(f"无法获取 {platform} 搜索结果")
+
+ parts = []
+ hasFilteredLinks = False
+ if response.get("visitKey") is not None:
+ parts.append(str(response["visitKey"]))
+ if includeLinks and response.get("links") and isinstance(response.get("links"), list) and len(response["links"]) > 0:
+ linksLines = await buildLinkLines(response, url)
+ if len(linksLines) > 0:
+ parts.append("\n".join(linksLines))
+ hasFilteredLinks = True
+ elif includeLinks and response.get("content"):
+ extractedUrls = extractUrlsFromText(response["content"])
+ if len(extractedUrls) > 0:
+ lines = [f"[{i + 1}] {item}" for i, item in enumerate(extractedUrls[:20])]
+ parts.append("\n".join(lines))
+ if response.get("content") is not None and (not includeLinks or not hasFilteredLinks):
+ contentText = stripLeadingLinkDump(response["content"]) if includeLinks and response.get("links") and isinstance(response.get("links"), list) else str(response["content"])
+ parts.append(str(contentText))
+
+ return {"platform": platform, "content": "\n".join(parts)}
+ except Exception as error:
+ return {"platform": platform, "content": f"{platform} 搜索失败: {str(error)}"}
+
+
+async def performImageSearch(platform, url):
+ try:
+ response = await Tools.Net.visit({"url": url, "include_image_links": True})
+ if not response:
+ raise Error(f"无法获取 {platform} 图片搜索结果")
+
+ parts = []
+ if response.get("visitKey") is not None:
+ parts.append(str(response["visitKey"]))
+
+ if response.get("imageLinks") and isinstance(response.get("imageLinks"), list) and len(response["imageLinks"]) > 0:
+ maxItems = 20
+ imagesLines = []
+ for index, link in enumerate(response["imageLinks"][:maxItems]):
+ lastSeg = str(link).split("/")[-1] or "image"
+ name = lastSeg.split("?")[0] or "image"
+ imagesLines.append(f"[{index + 1}] {name}")
+ parts.append("Images:")
+ parts.append("\n".join(imagesLines))
+
+ if response.get("content") is not None:
+ parts.append(str(response["content"]))
+
+ return {"platform": platform, "content": "\n".join(parts)}
+ except Exception as error:
+ return {"platform": platform, "content": f"{platform} 图片搜索失败: {str(error)}"}
+
+
+async def search_bing(query, includeLinks=False):
+ encodedQuery = quote(query)
+ url = f"https://cn.bing.com/search?q={encodedQuery}&FORM=HDRSC1"
+ return await performSearch('bing', url, includeLinks)
+
+
+async def search_baidu(query, pageStr=None, includeLinks=False):
+ page = 1
+ if pageStr:
+ page = int(str(pageStr), 10)
+ pn = (page - 1) * 10
+ encodedQuery = quote(query)
+ url = f"https://www.baidu.com/s?wd={encodedQuery}&pn={pn}"
+ return await performSearch('baidu', url, includeLinks)
+
+
+async def search_sogou(query, pageStr=None, includeLinks=False):
+ page = 1
+ if pageStr:
+ page = int(str(pageStr), 10)
+ encodedQuery = quote(query)
+ url = f"https://www.sogou.com/web?query={encodedQuery}&page={page}"
+ return await performSearch('sogou', url, includeLinks)
+
+
+async def search_quark(query, pageStr=None, includeLinks=False):
+ page = 1
+ if pageStr:
+ page = int(str(pageStr), 10)
+ encodedQuery = quote(query)
+ url = f"https://quark.sm.cn/s?q={encodedQuery}&page={page}"
+ return await performSearch('quark', url, includeLinks)
+
+
+async def search_bing_images(query):
+ encodedQuery = quote(query)
+ url = f"https://www.bing.com/images/search?q={encodedQuery}"
+ return await performImageSearch('bing_images', url)
+
+
+async def search_wikimedia_images(query):
+ encodedQuery = quote(query)
+ url = f"https://commons.wikimedia.org/wiki/Special:MediaSearch?type=image&search={encodedQuery}"
+ return await performImageSearch('wikimedia_images', url)
+
+
+async def search_duckduckgo_images(query):
+ encodedQuery = quote(query)
+ url = f"https://duckduckgo.com/?q={encodedQuery}&iax=images&ia=images"
+ return await performImageSearch('duckduckgo_images', url)
+
+
+async def search_ecosia_images(query):
+ encodedQuery = quote(query)
+ url = f"https://www.ecosia.org/images?q={encodedQuery}"
+ return await performImageSearch('ecosia_images', url)
+
+
+async def search_pexels_images(query):
+ encodedQuery = quote(query)
+ url = f"https://www.pexels.com/search/{encodedQuery}/"
+ return await performImageSearch('pexels_images', url)
+
+
+async def search_pixabay_images(query):
+ encodedQuery = quote(query)
+ url = f"https://pixabay.com/images/search/{encodedQuery}/"
+ return await performImageSearch('pixabay_images', url)
+
+
+searchFunctions = {
+ "bing": search_bing,
+ "baidu": search_baidu,
+ "sogou": search_sogou,
+ "quark": search_quark,
+}
+
+
+async def combined_search(query, platforms, includeLinks=True):
+ platformKeysRaw = platforms.split(',')
+ platformKeys = [p.strip() for p in platformKeysRaw if p.strip()]
+
+ async def runPlatform(platform):
+ searchFn = searchFunctions.get(platform)
+ if searchFn:
+ if platform == 'bing':
+ return await searchFn(query, includeLinks)
+ else:
+ return await searchFn(query, '1', includeLinks)
+ else:
+ return {"platform": platform, "success": False, "message": f"不支持的搜索平台: {platform}"}
+
+ return await asyncio.gather(*[runPlatform(p) for p in platformKeys])
+
+
+async def search(query, platforms=None, includeLinks=False):
+ return await combined_search(query, platforms or "bing,baidu,sogou,quark", includeLinks)
+
+
+async def search_web(query, platforms=None, includeLinks=False):
+ return await search(query, platforms, includeLinks)
+
+
+async def main():
+ result = await combined_search('如何学习编程', 'bing,baidu,sogou,quark')
+ console.log(json.dumps(result, indent=2, ensure_ascii=False, default=str))
+
+
+def wrap(coreFunction, parameterNames):
+ async def wrapped(params):
+ args = [params.get(name) for name in parameterNames]
+ return await coreFunction(*args)
+ return wrapped
+
+
+async def _complete_wrapper(func):
+ async def wrapper(params):
+ try:
+ result = await func(params)
+ complete({"success": True, "message": "搜索完成", "data": result})
+ except Exception as error:
+ complete({"success": False, "message": f"搜索失败: {str(error)}"})
+ return wrapper
+
+
+async def _make_export(coreFunction, parameterNames):
+ wrapped = wrap(coreFunction, parameterNames)
+
+ async def exportFn(params):
+ try:
+ result = await wrapped(params)
+ if isinstance(result, list):
+ complete({"success": True, "message": "搜索完成", "data": result})
+ else:
+ complete({"success": True, "message": "搜索完成", "data": result})
+ except Exception as error:
+ complete({"success": False, "message": f"搜索失败: {str(error)}"})
+ return exportFn
+
+
+# 由于 Python 无法在模块加载时 await,这里直接构造同步包装器
+def _make_sync_export(coreFunction, parameterNames):
+ def wrapped_sync(params):
+ async def _run():
+ args = [params.get(name) for name in parameterNames]
+ result = await coreFunction(*args)
+ if isinstance(result, list):
+ complete({"success": True, "message": "搜索完成", "data": result})
+ else:
+ complete({"success": True, "message": "搜索完成", "data": result})
+ try:
+ import asyncio
+ asyncio.get_event_loop().run_until_complete(_run())
+ except Exception as error:
+ complete({"success": False, "message": f"搜索失败: {str(error)}"})
+ return wrapped_sync
+
+
+# 导出:保持 async def 接口,由运行时调度
+async def _search_bing_export(params):
+ result = await search_bing(params.get("query"), params.get("includeLinks", False))
+ complete({"success": True, "message": "搜索完成", "data": result})
+
+
+async def _search_baidu_export(params):
+ result = await search_baidu(params.get("query"), params.get("page"), params.get("includeLinks", False))
+ complete({"success": True, "message": "搜索完成", "data": result})
+
+
+async def _search_sogou_export(params):
+ result = await search_sogou(params.get("query"), params.get("page"), params.get("includeLinks", False))
+ complete({"success": True, "message": "搜索完成", "data": result})
+
+
+async def _search_quark_export(params):
+ result = await search_quark(params.get("query"), params.get("page"), params.get("includeLinks", False))
+ complete({"success": True, "message": "搜索完成", "data": result})
+
+
+async def _search_export(params):
+ result = await search(params.get("query"), params.get("platforms"), params.get("includeLinks", False))
+ complete({"success": True, "message": "搜索完成", "data": result})
+
+
+async def _search_web_export(params):
+ result = await search_web(params.get("query"), params.get("platforms"), params.get("includeLinks", False))
+ complete({"success": True, "message": "搜索完成", "data": result})
+
+
+async def _search_bing_images_export(params):
+ result = await search_bing_images(params.get("query"))
+ complete({"success": True, "message": "图片搜索完成", "data": result})
+
+
+async def _search_wikimedia_images_export(params):
+ result = await search_wikimedia_images(params.get("query"))
+ complete({"success": True, "message": "图片搜索完成", "data": result})
+
+
+async def _search_duckduckgo_images_export(params):
+ result = await search_duckduckgo_images(params.get("query"))
+ complete({"success": True, "message": "图片搜索完成", "data": result})
+
+
+async def _search_ecosia_images_export(params):
+ result = await search_ecosia_images(params.get("query"))
+ complete({"success": True, "message": "图片搜索完成", "data": result})
+
+
+async def _search_pexels_images_export(params):
+ result = await search_pexels_images(params.get("query"))
+ complete({"success": True, "message": "图片搜索完成", "data": result})
+
+
+async def _search_pixabay_images_export(params):
+ result = await search_pixabay_images(params.get("query"))
+ complete({"success": True, "message": "图片搜索完成", "data": result})
+
+
+async def _combined_search_export(params):
+ result = await combined_search(params.get("query"), params.get("platforms"), params.get("includeLinks", True))
+ complete({"success": True, "message": "聚合搜索完成", "data": result})
+
+
+async def _main_export(params):
+ await main()
+
+
+exports.search_bing = _search_bing_export
+exports.search_baidu = _search_baidu_export
+exports.search_sogou = _search_sogou_export
+exports.search_quark = _search_quark_export
+exports.search = _search_export
+exports.search_web = _search_web_export
+exports.search_bing_images = _search_bing_images_export
+exports.search_wikimedia_images = _search_wikimedia_images_export
+exports.search_duckduckgo_images = _search_duckduckgo_images_export
+exports.search_ecosia_images = _search_ecosia_images_export
+exports.search_pexels_images = _search_pexels_images_export
+exports.search_pixabay_images = _search_pixabay_images_export
+exports.combined_search = _combined_search_export
+exports.main = _main_export
diff --git a/operit_py/vflow_trigger.py b/operit_py/vflow_trigger.py
new file mode 100644
index 0000000..e3e7001
--- /dev/null
+++ b/operit_py/vflow_trigger.py
@@ -0,0 +1,66 @@
+# METADATA
+# {
+# name: "vflow_trigger"
+# display_name: {
+# zh: "VFlow 触发器"
+# en: "VFlow Trigger"
+# }
+# description: {
+# zh: "触发vflow app的工作流。"
+# en: "Trigger VFlow app workflows."
+# }
+# enabledByDefault: false
+# category: "Workflow"
+# tools: [
+# {
+# name: "trigger_vflow_workflow"
+# description: {
+# zh: "根据 workflow_id 触发 VFlow 工作流(需安装 com.chaomixian.vflow)。"
+# en: "Trigger a VFlow workflow by workflow_id (requires com.chaomixian.vflow installed)."
+# }
+# parameters: [
+# { name: "workflow_id", description: { zh: "工作流 ID", en: "Workflow ID" }, type: "string", required: true }
+# ]
+# }
+# ]
+# }
+
+DEFAULT_ACTION = "com.chaomixian.vflow.EXECUTE_WORKFLOW_SHORTCUT"
+DEFAULT_COMPONENT = "com.chaomixian.vflow/.ui.common.ShortcutExecutorActivity"
+
+
+async def trigger_vflow_workflow(params):
+ if not params or not params.get("workflow_id"):
+ return {"success": False, "message": "workflow_id 不能为空"}
+
+ result = await Tools.System.intent({
+ "type": "activity",
+ "action": DEFAULT_ACTION,
+ "component": DEFAULT_COMPONENT,
+ "extras": {
+ "workflow_id": params["workflow_id"]
+ }
+ })
+
+ return {
+ "success": True,
+ "message": "已触发 VFlow 工作流",
+ "data": result
+ }
+
+
+async def wrapToolExecution(func, params):
+ try:
+ result = await func(params)
+ complete(result)
+ except Exception as error:
+ console.error(f"Tool {func.__name__} failed unexpectedly", error)
+ msg = str(getattr(error, "message", error))
+ complete({"success": False, "message": msg})
+
+
+async def trigger_vflow_workflow_wrapper(params):
+ await wrapToolExecution(trigger_vflow_workflow, params)
+
+
+exports.trigger_vflow_workflow = trigger_vflow_workflow_wrapper
diff --git a/operit_py/workflow.py b/operit_py/workflow.py
new file mode 100644
index 0000000..b796599
--- /dev/null
+++ b/operit_py/workflow.py
@@ -0,0 +1,274 @@
+# METADATA
+# {
+# "name": "workflow",
+# "display_name": {
+# "zh": "工作流管理",
+# "en": "Workflow Management"
+# },
+# "description": {
+# "zh": "工作流管理工具:创建/查询/更新/启用/禁用/删除/触发执行;支持 on_success/on_error 分支;支持语音触发(speech)。",
+# "en": "Workflow management tools for creating/querying/updating/enabling/disabling/deleting workflows, triggering execution, and branching via on_success/on_error. Supports speech trigger (speech)."
+# },
+# "category": "Workflow",
+# "enabledByDefault": true,
+# "tools": [
+# {
+# "name": "usage_advice",
+# "description": {
+# "zh": "工作流工具使用建议(给 AI):\n\n- 核心概念(请优先对齐这些语义):\n - 节点类型:trigger/execute/condition/logic/extract\n - 触发节点类型:manual/schedule/tasker/intent/speech\n - 参数引用(ParameterValue):静态值 vs 引用其他节点输出\n - 分支连线 condition 的语义:on_success/on_error/true/false/regex\n\n- 重要:create_workflow/update_workflow 的 nodes/connections 参数底层类型是 string(JSON 数组字符串)。\n - 本示例封装允许你直接传对象数组,封装层会自动 JSON.stringify。\n\n- 推荐流程:\n 1) 先 get_all_workflows 找到候选 workflow_id。\n 2) 再 get_workflow 获取 nodes/connections 全量结构。\n 3) 如果你要“整体替换” nodes/connections:构造完整的新数组后用 update_workflow 一次性覆盖。\n 4) 如果你只想“增量修改” nodes/connections:优先使用 patch_workflow(node_patches/connection_patches)。\n\n- 节点与连线的 ID:\n - 节点 id 可省略(服务端会生成),但如果你要创建 connections,强烈建议你在 nodes 里显式写好 id。\n - connections 里 source/target 可以用:\n - sourceNodeId/targetNodeId(推荐)\n - 或 source/target/from/to\n - 或 sourceIndex/targetIndex(按 nodes 数组下标)\n - 或 sourceNodeName/targetNodeName(不推荐:同名会歧义)\n\n- 分支连线 condition(核心):\n - 通用关键字(适用于任何节点类型):\n - condition = \"on_success\" | \"success\" | \"ok\":源节点成功时触发\n - condition = \"on_error\" | \"error\" | \"failed\":源节点失败时触发(失败分支/补救逻辑)\n - 对 ConditionNode / LogicNode:\n - condition 为空:默认代表 true 分支(相当于 \"true\")\n - condition = \"false\":false 分支\n - condition = 其它字符串:当作 Regex 匹配源节点输出字符串\n - 对非 Condition/Logic 节点:\n - condition 为空:等价于 on_success(表示“源节点执行成功就走”)\n - condition = on_error:表示“源节点失败就走”(失败分支)\n\n- 参数引用(ParameterValue):\n - 静态值:直接写字符串/数字/布尔值即可(会被当作 StaticValue)\n - 引用某节点输出:写对象 { nodeId: \"\" }\n - 兼容字段:nodeId / ref / refNodeId\n\n- 触发节点类型(TriggerNode.triggerType):\n - manual:手动触发(UI 点“触发工作流”)\n - schedule:定时触发(由 WorkManager 调度)\n - tasker:Tasker 事件触发\n - intent:系统广播 Intent 触发\n - speech:语音识别事件触发(当识别文本命中正则时触发;可多工作流同时触发)\n\n 触发配置 TriggerNode.triggerConfig(注意:值全是 string):\n - schedule:\n - schedule_type: interval | specific_time | cron\n - interval_ms: \"900000\" (15分钟)\n - specific_time: \"2026-01-04 10:30\" (格式依实现)\n - cron_expression: \"15 * * * *\" (简化 cron)\n - repeat: \"true\"/\"false\"\n - enabled: \"true\"/\"false\"\n - tasker:\n - command: \"start_meeting\" (当 Tasker params 中包含该字符串则触发)\n - intent:\n - action: \"com.example.MY_ACTION\" (当收到该 action 的 Intent 则触发)\n - speech:\n - pattern: \".*(打开|启动).*(对话|聊天|悬浮窗).*\" (正则;匹配识别文本)\n - ignore_case: \"true\"/\"false\" (可选,默认 true)\n - require_final: \"true\"/\"false\" (可选,默认 true;true 表示仅 final 结果触发)\n - cooldown_ms: \"3000\" (可选,默认 3000;每个节点的触发冷却)",
+# "en": "Workflow tool usage advice (for the AI):\n\n- Core concepts (align your reasoning with these semantics):\n - Node types: trigger/execute/condition/logic/extract\n - Trigger node types: manual/schedule/tasker/intent/speech\n - Parameter references (ParameterValue): static values vs references to another node output\n - Connection \"condition\" meaning: on_success/on_error/true/false/regex\n\n- Important: in create_workflow/update_workflow, nodes/connections are strings (JSON array strings) at the API layer.\n - This example wrapper lets you pass object arrays directly; it will JSON.stringify automatically.\n\n- Recommended flow:\n 1) Call get_all_workflows to find the candidate workflow_id.\n 2) Call get_workflow to retrieve the full nodes/connections structure.\n 3) If you want to replace nodes/connections entirely: build the full new arrays and use update_workflow once to overwrite.\n 4) If you only want incremental changes: prefer patch_workflow (node_patches/connection_patches).\n\n- Node and connection IDs:\n - Node id can be omitted (server will generate it), but if you need to create connections, strongly recommend explicitly setting node ids in nodes.\n - In connections, source/target can be provided as:\n - sourceNodeId/targetNodeId (recommended)\n - or source/target/from/to\n - or sourceIndex/targetIndex (index within nodes array)\n - or sourceNodeName/targetNodeName (not recommended: duplicates are ambiguous)\n\n- Connection condition (core):\n - Global keywords (works for any node type):\n - condition = \"on_success\" | \"success\" | \"ok\": trigger when the source node succeeds\n - condition = \"on_error\" | \"error\" | \"failed\": trigger when the source node fails (error branch / recovery)\n - For ConditionNode / LogicNode:\n - empty condition: defaults to true branch (equivalent to \"true\")\n - condition = \"false\": false branch\n - other string: treated as a Regex to match the source node output string\n - For non-Condition/Logic nodes:\n - empty condition: equivalent to on_success (proceed if the source node succeeded)\n - condition = on_error: proceed if the source node failed (error branch)\n\n- Parameter references (ParameterValue):\n - Static value: write a string/number/boolean directly (treated as StaticValue)\n - Reference another node output: write an object { nodeId: \"\" }\n - compatible fields: nodeId / ref / refNodeId\n\n- Trigger node types (TriggerNode.triggerType):\n - manual: manual trigger (tap \"trigger workflow\" in UI)\n - schedule: scheduled trigger (WorkManager)\n - tasker: triggered by Tasker events\n - intent: triggered by Android broadcast intents\n - speech: triggered by speech recognition events (fires when recognized text matches a regex; multiple workflows can match)\n\n Trigger configuration TriggerNode.triggerConfig (note: all values are strings):\n - schedule:\n - schedule_type: interval | specific_time | cron\n - interval_ms: \"900000\" (15 minutes)\n - specific_time: \"2026-01-04 10:30\" (format depends on implementation)\n - cron_expression: \"15 * * * *\" (simplified cron)\n - repeat: \"true\"/\"false\"\n - enabled: \"true\"/\"false\"\n - tasker:\n - command: \"start_meeting\" (triggered when Tasker params contains this string)\n - intent:\n - action: \"com.example.MY_ACTION\" (triggered when receiving this action)\n - speech:\n - pattern: \".*(open|start).*(chat|floating).*\" (regex; matches recognized text)\n - ignore_case: \"true\"/\"false\" (optional, default true)\n - require_final: \"true\"/\"false\" (optional, default true; if true, only final results trigger)\n - cooldown_ms: \"3000\" (optional, default 3000; per-node cooldown)"
+# },
+# "parameters": []
+# },
+# {
+# "name": "get_all_workflows",
+# "description": { "zh": "获取所有工作流列表(只含概要信息:ID/名称/启用/统计等)。", "en": "List all workflows (summary only: id/name/enabled/stats, etc.)." },
+# "parameters": []
+# },
+# {
+# "name": "get_workflow",
+# "description": { "zh": "获取指定工作流完整详情(nodes + connections)。", "en": "Get full details of a specific workflow (nodes + connections)." },
+# "parameters": [
+# { "name": "workflow_id", "description": { "zh": "工作流 ID", "en": "Workflow ID" }, "type": "string", "required": true }
+# ]
+# },
+# {
+# "name": "create_workflow",
+# "description": { "zh": "创建工作流。\n\n参数说明:\n- nodes: JSON 数组字符串(推荐传对象数组,让封装自动 stringify)\n- connections: JSON 数组字符串(同上)\n\n节点类型(node.type):\n- trigger / execute / condition / logic / extract\n\nExecute 节点:\n- actionType: 工具名(如 \"visit_web\" / \"list_files\" / \"get_system_setting\" ...)\n- actionConfig: 工具参数对象,支持 ParameterValue(静态值/节点引用)\n\nCondition 节点:\n- left/right: ParameterValue\n- operator: EQ/NE/GT/GTE/LT/LTE/CONTAINS/NOT_CONTAINS/IN/NOT_IN\n\nLogic 节点:\n- operator: AND/OR\n\nExtract(运算)节点:\n- 说明:node.type 仍然是 \"extract\"(兼容旧数据),但语义更接近“运算器/计算节点”。\n- source: ParameterValue(部分模式不需要,例如 RANDOM_INT)\n- mode:\n - REGEX:正则提取\n - expression: 正则表达式\n - group/defaultValue 可选\n - JSON:JSON 路径提取\n - expression: JSON 路径(简化实现)\n - defaultValue 可选\n - SUB:字符串截取\n - startIndex: 起始下标\n - length: 长度(-1 表示到结尾)\n - defaultValue 可选\n - CONCAT:字符串拼接\n - others: ParameterValue[](被拼接项列表)\n - RANDOM_INT:随机整数\n - randomMin/randomMax\n - useFixed: 是否使用固定值(可选)\n - fixedValue: 固定整数(可选,仅 useFixed=true 时生效;输出仍为数字字符串)\n - RANDOM_STRING:随机字符串\n - randomStringLength: 长度\n - randomStringCharset: 字符集(可选,默认字母数字)\n - useFixed: 是否使用固定值(可选)\n - fixedValue: 固定字符串(可选,仅 useFixed=true 时生效)", "en": "Create a workflow.\n\nParameter notes:\n- nodes: JSON array string (recommended: pass object arrays and let the wrapper stringify)\n- connections: JSON array string (same as above)\n\nNode types (node.type):\n- trigger / execute / condition / logic / extract\n\nExecute node:\n- actionType: tool name (e.g. \"visit_web\" / \"list_files\" / \"get_system_setting\" ...)\n- actionConfig: tool parameter object, supports ParameterValue (static value / node reference)\n\nCondition node:\n- left/right: ParameterValue\n- operator: EQ/NE/GT/GTE/LT/LTE/CONTAINS/NOT_CONTAINS/IN/NOT_IN\n\nLogic node:\n- operator: AND/OR\n\nExtract (operator) node:\n- Note: node.type is still \"extract\" for backward compatibility, but it behaves like an \"operator\" node.\n- source: ParameterValue (not required for some modes like RANDOM_INT)\n- mode:\n - REGEX: regex extraction\n - expression: regex pattern\n - group/defaultValue are optional\n - JSON: JSON path extraction\n - expression: JSON path (simplified)\n - defaultValue optional\n - SUB: substring\n - startIndex\n - length (-1 means to end)\n - defaultValue optional\n - CONCAT: string concatenation\n - others: ParameterValue[]\n - RANDOM_INT: random integer\n - randomMin/randomMax\n - useFixed: whether to use a fixed value (optional)\n - fixedValue: fixed integer (optional, only effective when useFixed=true; output is still a numeric string)\n - RANDOM_STRING: random string\n - randomStringLength\n - randomStringCharset (optional, default: alphanumeric)\n - useFixed: whether to use a fixed value (optional)\n - fixedValue: fixed string (optional, only effective when useFixed=true)" },
+# "parameters": [
+# { "name": "name", "description": { "zh": "工作流名称", "en": "Workflow name" }, "type": "string", "required": true },
+# { "name": "description", "description": { "zh": "工作流描述(可选)", "en": "Workflow description (optional)" }, "type": "string", "required": false },
+# { "name": "nodes", "description": { "zh": "可选,节点 JSON 数组字符串(或直接传节点数组,由封装 stringify)", "en": "Optional. Nodes JSON array string (or pass an array and the wrapper will stringify)." }, "type": "string", "required": false },
+# { "name": "connections", "description": { "zh": "可选,连线 JSON 数组字符串(或直接传连线数组,由封装 stringify)", "en": "Optional. Connections JSON array string (or pass an array and the wrapper will stringify)." }, "type": "string", "required": false },
+# { "name": "enabled", "description": { "zh": "可选,是否启用(默认 true)", "en": "Optional. Whether to enable (default: true)." }, "type": "boolean", "required": false }
+# ]
+# },
+# {
+# "name": "update_workflow",
+# "description": { "zh": "更新工作流。\n\n注意:update_workflow 的 nodes / connections 是“整体覆盖”。\n- 若你只改其中一部分,推荐使用 patch_workflow。\n- 或者:get_workflow 取回旧结构 -> 本地构造新数组(保留未改部分)-> update_workflow 一次性传回。", "en": "Update a workflow.\n\nNote: nodes/connections in update_workflow are full overwrites.\n- If you only change part of them, prefer patch_workflow.\n- Or: call get_workflow to fetch the old structure -> build new arrays locally (keeping unchanged parts) -> call update_workflow once." },
+# "parameters": [
+# { "name": "workflow_id", "description": { "zh": "工作流 ID", "en": "Workflow ID" }, "type": "string", "required": true },
+# { "name": "name", "description": { "zh": "可选,新名称", "en": "Optional. New name." }, "type": "string", "required": false },
+# { "name": "description", "description": { "zh": "可选,新描述", "en": "Optional. New description." }, "type": "string", "required": false },
+# { "name": "nodes", "description": { "zh": "可选,节点 JSON 数组字符串(整体覆盖)", "en": "Optional. Nodes JSON array string (full overwrite)." }, "type": "string", "required": false },
+# { "name": "connections", "description": { "zh": "可选,连线 JSON 数组字符串(整体覆盖)", "en": "Optional. Connections JSON array string (full overwrite)." }, "type": "string", "required": false },
+# { "name": "enabled", "description": { "zh": "可选,是否启用", "en": "Optional. Whether to enable." }, "type": "boolean", "required": false }
+# ]
+# },
+# {
+# "name": "patch_workflow",
+# "description": { "zh": "差异更新工作流(增量 patch)。\n\n使用 node_patches / connection_patches 传入 JSON 数组字符串:\n- op: add | update | remove\n- id: 可选\n- node / connection: 对象\n\n说明:\n- add:必须提供 node/connection\n- update:必须提供 id 或 node.id/connection.id\n- remove:必须提供 id", "en": "Patch a workflow (incremental update).\n\nUse node_patches / connection_patches as JSON array strings:\n- op: add | update | remove\n- id: optional\n- node / connection: object\n\nNotes:\n- add: must provide node/connection\n- update: must provide id OR node.id/connection.id\n- remove: must provide id" },
+# "parameters": [
+# { "name": "workflow_id", "description": { "zh": "工作流 ID", "en": "Workflow ID" }, "type": "string", "required": true },
+# { "name": "name", "description": { "zh": "可选,新名称", "en": "Optional. New name." }, "type": "string", "required": false },
+# { "name": "description", "description": { "zh": "可选,新描述", "en": "Optional. New description." }, "type": "string", "required": false },
+# { "name": "enabled", "description": { "zh": "可选,是否启用", "en": "Optional. Whether to enable." }, "type": "boolean", "required": false },
+# { "name": "node_patches", "description": { "zh": "可选,节点 patch JSON 数组字符串", "en": "Optional. Node patch JSON array string." }, "type": "string", "required": false },
+# { "name": "connection_patches", "description": { "zh": "可选,连线 patch JSON 数组字符串", "en": "Optional. Connection patch JSON array string." }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "enable_workflow",
+# "description": { "zh": "启用指定工作流。", "en": "Enable a specific workflow." },
+# "parameters": [
+# { "name": "workflow_id", "description": { "zh": "工作流 ID", "en": "Workflow ID" }, "type": "string", "required": true }
+# ]
+# },
+# {
+# "name": "disable_workflow",
+# "description": { "zh": "禁用指定工作流。", "en": "Disable a specific workflow." },
+# "parameters": [
+# { "name": "workflow_id", "description": { "zh": "工作流 ID", "en": "Workflow ID" }, "type": "string", "required": true }
+# ]
+# },
+# {
+# "name": "delete_workflow",
+# "description": { "zh": "删除指定工作流。", "en": "Delete a specific workflow." },
+# "parameters": [
+# { "name": "workflow_id", "description": { "zh": "工作流 ID", "en": "Workflow ID" }, "type": "string", "required": true }
+# ]
+# },
+# {
+# "name": "trigger_workflow",
+# "description": { "zh": "触发指定工作流执行(相当于 UI 手动触发)。", "en": "Trigger execution of a workflow (equivalent to manual trigger in UI)." },
+# "parameters": [
+# { "name": "workflow_id", "description": { "zh": "工作流 ID", "en": "Workflow ID" }, "type": "string", "required": true }
+# ]
+# }
+# ]
+# }
+
+
+async def usage_advice(params):
+ return {
+ "success": True,
+ "message": "请阅读 workflow 工具 METADATA 中的 usage_advice 说明:包含节点/连线 schema、分支 condition 规则、触发类型与配置示例。建议在脚本里直接使用 Tools.Workflow.getAll/get/create/update/patch/setEnabled/enable/disable/delete/trigger 等封装方法。"
+ }
+
+
+# 获取所有工作流
+async def get_all_workflows(params=None):
+ if params is None:
+ params = {}
+ data = await Tools.Workflow.getAll()
+ return {
+ "success": True,
+ "message": "成功获取工作流列表",
+ "data": data
+ }
+
+
+# 创建新工作流
+async def create_workflow(params):
+ data = await Tools.Workflow.create(
+ params.get("name"),
+ params.get("description") or "",
+ params.get("nodes") if params.get("nodes") is not None else None,
+ params.get("connections") if params.get("connections") is not None else None,
+ params.get("enabled")
+ )
+ return {
+ "success": True,
+ "message": "成功创建工作流",
+ "data": data
+ }
+
+
+# 获取工作流详情
+async def get_workflow(params):
+ data = await Tools.Workflow.get(params["workflow_id"])
+ return {
+ "success": True,
+ "message": "成功获取工作流详情",
+ "data": data
+ }
+
+
+# 更新工作流
+async def update_workflow(params):
+ workflow_id = params.get("workflow_id")
+ updates = {k: v for k, v in params.items() if k != "workflow_id"}
+ data = await Tools.Workflow.update(workflow_id, updates)
+ return {
+ "success": True,
+ "message": "成功更新工作流",
+ "data": data
+ }
+
+
+# 差异更新工作流(增量 patch)
+async def patch_workflow(params):
+ workflow_id = params.get("workflow_id")
+ patch = {k: v for k, v in params.items() if k != "workflow_id"}
+ data = await Tools.Workflow.patch(workflow_id, patch)
+ return {
+ "success": True,
+ "message": "成功差异更新工作流",
+ "data": data
+ }
+
+
+# 启用工作流
+async def enable_workflow(params):
+ data = await Tools.Workflow.enable(params["workflow_id"])
+ return {
+ "success": True,
+ "message": "成功启用工作流",
+ "data": data
+ }
+
+
+# 禁用工作流
+async def disable_workflow(params):
+ data = await Tools.Workflow.disable(params["workflow_id"])
+ return {
+ "success": True,
+ "message": "成功禁用工作流",
+ "data": data
+ }
+
+
+# 删除工作流
+async def delete_workflow(params):
+ data = await getattr(Tools.Workflow, "delete")(params["workflow_id"])
+ return {
+ "success": True,
+ "message": "成功删除工作流",
+ "data": data
+ }
+
+
+# 触发工作流执行
+async def trigger_workflow(params):
+ data = await Tools.Workflow.trigger(params["workflow_id"])
+ return {
+ "success": True,
+ "message": "成功触发工作流",
+ "data": data
+ }
+
+
+# 包装工具执行,处理错误和结果
+async def wrapToolExecution(func, params):
+ try:
+ result = await func(params)
+ complete(result)
+ except Exception as error:
+ console.error(f"Tool {func.__name__} failed unexpectedly", error)
+ complete({"success": False, "message": str(getattr(error, "message", None) or error)})
+
+
+async def _exported_usage_advice(params=None):
+ if params is None:
+ params = {}
+ await wrapToolExecution(usage_advice, params)
+
+
+async def _exported_get_all_workflows(params=None):
+ if params is None:
+ params = {}
+ await wrapToolExecution(get_all_workflows, {})
+
+
+async def _exported_create_workflow(params):
+ await wrapToolExecution(create_workflow, params)
+
+
+async def _exported_get_workflow(params):
+ await wrapToolExecution(get_workflow, params)
+
+
+async def _exported_update_workflow(params):
+ await wrapToolExecution(update_workflow, params)
+
+
+async def _exported_patch_workflow(params):
+ await wrapToolExecution(patch_workflow, params)
+
+
+async def _exported_enable_workflow(params):
+ await wrapToolExecution(enable_workflow, params)
+
+
+async def _exported_disable_workflow(params):
+ await wrapToolExecution(disable_workflow, params)
+
+
+async def _exported_delete_workflow(params):
+ await wrapToolExecution(delete_workflow, params)
+
+
+async def _exported_trigger_workflow(params):
+ await wrapToolExecution(trigger_workflow, params)
+
+
+exports.usage_advice = _exported_usage_advice
+exports.get_all_workflows = _exported_get_all_workflows
+exports.create_workflow = _exported_create_workflow
+exports.get_workflow = _exported_get_workflow
+exports.update_workflow = _exported_update_workflow
+exports.patch_workflow = _exported_patch_workflow
+exports.enable_workflow = _exported_enable_workflow
+exports.disable_workflow = _exported_disable_workflow
+exports.delete_workflow = _exported_delete_workflow
+exports.trigger_workflow = _exported_trigger_workflow
diff --git a/operit_py/xai_draw.py b/operit_py/xai_draw.py
new file mode 100644
index 0000000..4484282
--- /dev/null
+++ b/operit_py/xai_draw.py
@@ -0,0 +1,580 @@
+# METADATA
+# {
+# "name": "xai_draw",
+# "display_name": {"zh": "xAI 图片与视频", "en": "xAI Images and Video"},
+# "description": {"zh": "使用 xAI 官方接口生成图片和视频,并保存到本地。", "en": "Generate images and videos with the official xAI APIs and save them locally."},
+# "env": [
+# {"name": "XAI_API_KEY", "description": {"zh": "xAI API Key(必填)", "en": "xAI API key (required)"}, "required": True},
+# {"name": "XAI_IMAGE_MODEL", "description": {"zh": "默认图片模型(可选;未传 model 时使用,默认 grok-2-image-1212)", "en": "Default image model (optional; used when model is omitted, default grok-2-image-1212)"}, "required": False},
+# {"name": "XAI_VIDEO_MODEL", "description": {"zh": "默认视频模型(可选;未传 model 时使用,默认 grok-imagine-video)", "en": "Default video model (optional; used when model is omitted, default grok-imagine-video)"}, "required": False}
+# ],
+# "category": "Draw",
+# "tools": [
+# {"name": "draw_image", "description": {"zh": "根据提示词调用 xAI 图像生成 API 生成图片,保存到本地并返回 Markdown 图片提示。", "en": "Generate an image via the xAI image generation API using a prompt, save it locally, and return a Markdown image reference."}, "parameters": [
+# {"name": "prompt", "description": {"zh": "绘图提示词(英文或中文皆可)", "en": "Prompt for image generation (Chinese or English)"}, "type": "string", "required": True},
+# {"name": "model", "description": {"zh": "xAI 图像模型名称;不传则优先取 XAI_IMAGE_MODEL,再用默认值 grok-2-image-1212", "en": "xAI image model name; falls back to XAI_IMAGE_MODEL, then grok-2-image-1212"}, "type": "string", "required": False},
+# {"name": "size", "description": {"zh": "图片尺寸,例如 1024x1024(可选)", "en": "Image size, e.g. 1024x1024 (optional)"}, "type": "string", "required": False},
+# {"name": "file_name", "description": {"zh": "自定义保存到本地的文件名(不含路径和扩展名)", "en": "Custom output file name (without path or extension)"}, "type": "string", "required": False}
+# ]},
+# {"name": "draw_video", "description": {"zh": "根据提示词调用 xAI 官方视频生成 API 生成视频,支持文生视频、图生视频和视频编辑,轮询完成后下载到本地并返回本地视频链接提示。", "en": "Generate a video with the official xAI video API. Supports text-to-video, image-to-video, and video editing. Polls until completion, downloads locally, and returns local video link hints."}, "parameters": [
+# {"name": "prompt", "description": {"zh": "视频提示词", "en": "Video prompt"}, "type": "string", "required": True},
+# {"name": "model", "description": {"zh": "视频模型;不传则优先取 XAI_VIDEO_MODEL,再用默认值 grok-imagine-video", "en": "Video model; falls back to XAI_VIDEO_MODEL, then grok-imagine-video"}, "type": "string", "required": False},
+# {"name": "aspect_ratio", "description": {"zh": "输出比例,可选 1:1、16:9、9:16、4:3、3:4、3:2、2:3;默认 16:9;视频编辑模式不支持", "en": "Output aspect ratio. Supported: 1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3. Defaults to 16:9; not supported for video editing."}, "type": "string", "required": False},
+# {"name": "resolution", "description": {"zh": "输出分辨率,仅支持 480p 或 720p;默认 480p;视频编辑模式不支持", "en": "Output resolution, only 480p or 720p. Defaults to 480p; not supported for video editing."}, "type": "string", "required": False},
+# {"name": "duration", "description": {"zh": "输出时长,支持 1-15 秒;默认 5;视频编辑模式不支持", "en": "Output duration from 1 to 15 seconds. Defaults to 5; not supported for video editing."}, "type": "number", "required": False},
+# {"name": "image_url", "description": {"zh": "图生视频输入图 URL(可选)", "en": "Input image URL for image-to-video (optional)"}, "type": "string", "required": False},
+# {"name": "image_path", "description": {"zh": "图生视频输入图本地路径(可选,会转成 data URL)", "en": "Local input image path for image-to-video (optional; converted to a data URL)"}, "type": "string", "required": False},
+# {"name": "video_url", "description": {"zh": "视频编辑输入视频 URL(可选)", "en": "Input video URL for video editing (optional)"}, "type": "string", "required": False},
+# {"name": "file_name", "description": {"zh": "自定义保存到本地的文件名(不含路径和扩展名)", "en": "Custom output file name (without path or extension)"}, "type": "string", "required": False},
+# {"name": "poll_interval_ms", "description": {"zh": "轮询间隔毫秒数,默认 5000", "en": "Polling interval in milliseconds, default 5000"}, "type": "number", "required": False},
+# {"name": "max_wait_time_ms", "description": {"zh": "最大等待毫秒数,默认 600000", "en": "Maximum wait time in milliseconds, default 600000"}, "type": "number", "required": False}
+# ]}
+# ]
+# }
+
+import json
+import re
+import time
+import math
+from urllib.parse import quote, unquote
+
+Error = Exception
+
+HTTP_TIMEOUT_MS = 600000
+client = (
+ OkHttp.newBuilder()
+ .connectTimeout(HTTP_TIMEOUT_MS)
+ .readTimeout(HTTP_TIMEOUT_MS)
+ .writeTimeout(HTTP_TIMEOUT_MS)
+ .build()
+)
+
+DEFAULT_IMAGE_MODEL = "grok-2-image-1212"
+DEFAULT_VIDEO_MODEL = "grok-imagine-video"
+DEFAULT_POLL_INTERVAL_MS = 5000
+DEFAULT_MAX_WAIT_TIME_MS = 600000
+DEFAULT_VIDEO_ASPECT_RATIO = "16:9"
+DEFAULT_VIDEO_RESOLUTION = "480p"
+DEFAULT_VIDEO_DURATION = 5
+VIDEO_ASPECT_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"]
+VIDEO_RESOLUTIONS = ["480p", "720p"]
+MIN_VIDEO_DURATION = 1
+MAX_VIDEO_DURATION = 15
+
+API_BASE_URL = "https://api.x.ai/v1"
+IMAGE_API_ENDPOINT = f"{API_BASE_URL}/images/generations"
+VIDEO_GENERATION_ENDPOINT = f"{API_BASE_URL}/videos/generations"
+
+DRAW_ROOT_DIR = getPluginConfigDir("draw")
+STORAGE_DIR = f"{DRAW_ROOT_DIR}/xai_draw"
+DRAWS_DIR = f"{STORAGE_DIR}/draws"
+VIDEOS_DIR = f"{STORAGE_DIR}/videos"
+
+
+def getErrorMessage(error):
+ if isinstance(error, Exception):
+ return str(error)
+ return str(error)
+
+
+def getErrorStack(error):
+ if isinstance(error, Exception):
+ import traceback
+ return "".join(traceback.format_exception(type(error), error, error.__traceback__))
+ return None
+
+
+def isRecord(value):
+ return isinstance(value, dict)
+
+
+def getApiKey():
+ apiKey = getEnv("XAI_API_KEY")
+ if not apiKey:
+ raise Error("XAI_API_KEY 未配置,请在环境变量中设置 xAI 的 API Key。")
+ return apiKey
+
+
+def getDefaultImageModel():
+ fromEnv = str(getEnv("XAI_IMAGE_MODEL") or "").strip()
+ return fromEnv or DEFAULT_IMAGE_MODEL
+
+
+def getDefaultVideoModel():
+ fromEnv = str(getEnv("XAI_VIDEO_MODEL") or "").strip()
+ return fromEnv or DEFAULT_VIDEO_MODEL
+
+
+def sanitizeFileName(name, fallbackPrefix):
+ safe = re.sub(r'[\\/:*?"<>|]', "_", str(name or "")).strip()
+ if not safe:
+ return f"{fallbackPrefix}_{int(time.time() * 1000)}"
+ return safe[:80]
+
+
+def buildFileName(prompt, customName, fallbackPrefix):
+ if customName and customName.strip():
+ return sanitizeFileName(customName, fallbackPrefix)
+ shortPrompt = f"{prompt[:40]}..." if len(prompt) > 40 else prompt
+ base = sanitizeFileName(shortPrompt or fallbackPrefix, fallbackPrefix)
+ return f"{base}_{int(time.time() * 1000)}"
+
+
+def guessExtensionFromUrl(url, fallback):
+ match = re.search(r"\.(png|jpg|jpeg|webp|gif|mp4|mov|webm|mkv)(?:\?|#|$)", str(url or ""), re.IGNORECASE)
+ if match and match.group(1):
+ return match.group(1).lower()
+ return fallback
+
+
+def guessMimeTypeFromPath(path):
+ normalized = str(path or "").strip().lower()
+ if normalized.endswith(".png"):
+ return "image/png"
+ if normalized.endswith(".webp"):
+ return "image/webp"
+ if normalized.endswith(".gif"):
+ return "image/gif"
+ if normalized.endswith(".jpg") or normalized.endswith(".jpeg"):
+ return "image/jpeg"
+ return "application/octet-stream"
+
+
+def isProbablyUrl(value):
+ return bool(re.match(r"^https?://", str(value or "").strip(), re.IGNORECASE))
+
+
+def normalizePositiveInteger(value, fallback):
+ if value is None or value == "":
+ return fallback
+ parsed = value if isinstance(value, (int, float)) else int(str(value), 10)
+ if not math.isfinite(parsed) or parsed <= 0:
+ return fallback
+ return int(parsed)
+
+
+def normalizeVideoAspectRatio(value):
+ raw = str(value or "").strip()
+ if not raw:
+ return DEFAULT_VIDEO_ASPECT_RATIO
+ if raw not in VIDEO_ASPECT_RATIOS:
+ raise Error(f"aspect_ratio 仅支持 {' 或 '.join(VIDEO_ASPECT_RATIOS)}。")
+ return raw
+
+
+def normalizeVideoResolution(value):
+ raw = str(value or "").strip().lower()
+ if not raw:
+ return DEFAULT_VIDEO_RESOLUTION
+ if raw not in VIDEO_RESOLUTIONS:
+ raise Error(f"resolution 仅支持 {' 或 '.join(VIDEO_RESOLUTIONS)}。")
+ return raw
+
+
+def normalizeVideoDuration(value):
+ if value is None or value == "":
+ return DEFAULT_VIDEO_DURATION
+ parsed = value if isinstance(value, (int, float)) else int(str(value), 10)
+ if not math.isfinite(parsed):
+ raise Error("duration 必须是数字。")
+ normalized = int(parsed)
+ if normalized < MIN_VIDEO_DURATION or normalized > MAX_VIDEO_DURATION:
+ raise Error(f"duration 仅支持 {MIN_VIDEO_DURATION}-{MAX_VIDEO_DURATION} 秒。")
+ return normalized
+
+
+async def ensureDirectories():
+ dirs = [DRAW_ROOT_DIR, STORAGE_DIR, DRAWS_DIR, VIDEOS_DIR]
+ for dir_ in dirs:
+ try:
+ result = await Tools.Files.mkdir(dir_)
+ if not result.successful:
+ console.warn(f"创建目录失败(可能已存在): {dir_} -> {result.details}")
+ except Exception as error:
+ console.warn(f"创建目录异常: {dir_} -> {getErrorMessage(error)}")
+
+
+async def parseJsonResponse(response, label):
+ try:
+ parsed = json.loads(response.content)
+ if not isRecord(parsed):
+ raise Error("响应不是对象")
+ return parsed
+ except Exception as error:
+ raise Error(f"解析 {label} 响应失败: {getErrorMessage(error)}")
+
+
+def extractApiErrorMessage(payload):
+ directError = payload.get("error")
+ if isinstance(directError, str) and directError.strip():
+ return directError.strip()
+ if isRecord(directError):
+ if isinstance(directError.get("message"), str) and directError["message"].strip():
+ return directError["message"].strip()
+ if isinstance(directError.get("code"), str) and directError["code"].strip():
+ return directError["code"].strip()
+ return ""
+
+
+async def readLocalImageAsDataUrl(filePath):
+ trimmedPath = str(filePath or "").strip()
+ if not trimmedPath:
+ raise Error("image_path 不能为空。")
+
+ existsResult = await Tools.Files.exists(trimmedPath)
+ if not existsResult.exists:
+ raise Error(f"本地图片不存在: {trimmedPath}")
+
+ binaryResult = await Tools.Files.readBinary(trimmedPath)
+ base64Content = (
+ str(binaryResult.contentBase64).strip()
+ if binaryResult and getattr(binaryResult, "contentBase64", None)
+ else ""
+ )
+ if not base64Content:
+ raise Error(f"读取本地图片失败: {trimmedPath}")
+
+ return f"data:{guessMimeTypeFromPath(trimmedPath)};base64,{base64Content}"
+
+
+async def resolveVideoInputs(imageUrl, imagePath, videoUrl):
+ trimmedImageUrl = str(imageUrl or "").strip()
+ trimmedImagePath = str(imagePath or "").strip()
+ trimmedVideoUrl = str(videoUrl or "").strip()
+
+ imageInputCount = (1 if trimmedImageUrl else 0) + (1 if trimmedImagePath else 0)
+ if imageInputCount > 1:
+ raise Error("image_url 和 image_path 只能二选一,请不要同时传。")
+ if trimmedVideoUrl and imageInputCount > 0:
+ raise Error("视频生成一次只能使用一种输入源:纯文本、图片或视频。请不要同时传 image_* 和 video_url。")
+
+ if trimmedImageUrl:
+ if not isProbablyUrl(trimmedImageUrl):
+ raise Error("image_url 必须是 http 或 https 链接。")
+ return {"image": trimmedImageUrl, "input_type": "image_url"}
+
+ if trimmedImagePath:
+ return {"image": await readLocalImageAsDataUrl(trimmedImagePath), "input_type": "image_path"}
+
+ if trimmedVideoUrl:
+ if not isProbablyUrl(trimmedVideoUrl):
+ raise Error("video_url 必须是 http 或 https 链接。")
+ return {"video_url": trimmedVideoUrl, "input_type": "video_url"}
+
+ return {"input_type": "text"}
+
+
+async def callXaiImageApi(params):
+ apiKey = getApiKey()
+ modelFromParam = str(params.get("model") or "").strip()
+ effectiveModel = modelFromParam or getDefaultImageModel()
+
+ body = {
+ "model": effectiveModel,
+ "prompt": params.get("prompt"),
+ }
+
+ size = str(params.get("size") or "").strip()
+ if size:
+ body["size"] = size
+
+ request = (
+ client.newRequest()
+ .url(IMAGE_API_ENDPOINT)
+ .method("POST")
+ .headers({
+ "accept": "application/json",
+ "content-type": "application/json",
+ "Authorization": f"Bearer {apiKey}",
+ })
+ .body(json.dumps(body), "json")
+ )
+
+ response = await request.build().execute()
+ if not response.isSuccessful():
+ raise Error(f"xAI 图片 API 调用失败: {response.statusCode} - {response.content}")
+
+ parsed = await parseJsonResponse(response, "xAI 图片生成")
+ data = parsed.get("data") if isinstance(parsed.get("data"), list) else []
+ first = data[0] if len(data) > 0 and isRecord(data[0]) else None
+ imageUrl = str(first.get("url")).strip() if first and isinstance(first.get("url"), str) else ""
+
+ if not imageUrl:
+ raise Error("xAI 响应中未找到图片 URL,请检查模型和参数是否正确。")
+
+ return {"image_url": imageUrl, "effective_model": effectiveModel}
+
+
+async def createVideoTask(params):
+ apiKey = getApiKey()
+ modelFromParam = str(params.get("model") or "").strip()
+ effectiveModel = modelFromParam or getDefaultVideoModel()
+
+ body = {
+ "model": effectiveModel,
+ "prompt": str(params.get("prompt") or "").strip(),
+ }
+
+ isVideoEdit = bool(params.get("video_url"))
+ if not isVideoEdit:
+ body["aspect_ratio"] = normalizeVideoAspectRatio(params.get("aspect_ratio"))
+ body["resolution"] = normalizeVideoResolution(params.get("resolution"))
+ body["duration"] = normalizeVideoDuration(params.get("duration"))
+
+ if params.get("image"):
+ body["image"] = {"url": params.get("image")}
+ if params.get("video_url"):
+ body["video_url"] = params.get("video_url")
+
+ request = (
+ client.newRequest()
+ .url(VIDEO_GENERATION_ENDPOINT)
+ .method("POST")
+ .headers({
+ "accept": "application/json",
+ "content-type": "application/json",
+ "Authorization": f"Bearer {apiKey}",
+ })
+ .body(json.dumps(body), "json")
+ )
+
+ response = await request.build().execute()
+ if not response.isSuccessful():
+ raise Error(f"xAI 视频创建任务失败: {response.statusCode} - {response.content}")
+
+ parsed = await parseJsonResponse(response, "xAI 视频创建")
+ requestId = str(parsed.get("request_id")).strip() if isinstance(parsed.get("request_id"), str) else ""
+ status = str(parsed.get("status")).strip() if isinstance(parsed.get("status"), str) else ""
+
+ if not requestId:
+ raise Error(f"xAI 视频创建响应中未找到 request_id: {response.content}")
+
+ return {
+ "request_id": requestId,
+ "status": status,
+ "effective_model": effectiveModel,
+ "aspect_ratio": body.get("aspect_ratio") if isinstance(body.get("aspect_ratio"), str) else None,
+ "resolution": body.get("resolution") if isinstance(body.get("resolution"), str) else None,
+ "duration": body.get("duration") if isinstance(body.get("duration"), (int, float)) else None,
+ }
+
+
+async def queryVideoTaskStatus(requestId):
+ apiKey = getApiKey()
+ endpoint = f"{API_BASE_URL}/videos/{quote(requestId)}"
+ request = (
+ client.newRequest()
+ .url(endpoint)
+ .method("GET")
+ .headers({
+ "accept": "application/json",
+ "Authorization": f"Bearer {apiKey}",
+ })
+ )
+
+ response = await request.build().execute()
+ if not response.isSuccessful():
+ raise Error(f"xAI 视频查询失败: {response.statusCode} - {response.content}")
+
+ parsed = await parseJsonResponse(response, "xAI 视频查询")
+ status = str(parsed.get("status")).strip() if isinstance(parsed.get("status"), str) else ""
+ video = parsed.get("video") if isRecord(parsed.get("video")) else None
+ videoUrl = str(video.get("url")).strip() if video and isinstance(video.get("url"), str) else ""
+ contentType = str(video.get("content_type")).strip() if video and isinstance(video.get("content_type"), str) else ""
+ expiresAt = str(video.get("expires_at")).strip() if video and isinstance(video.get("expires_at"), str) else ""
+ duration = video.get("duration") if video and isinstance(video.get("duration"), (int, float)) else None
+ respectModeration = str(video.get("respect_moderation")).strip() if video and isinstance(video.get("respect_moderation"), str) else ""
+ errorMessage = extractApiErrorMessage(parsed)
+
+ return {
+ "status": status,
+ "video_url": videoUrl,
+ "content_type": contentType,
+ "expires_at": expiresAt,
+ "duration": duration,
+ "respect_moderation": respectModeration,
+ "error_message": errorMessage,
+ }
+
+
+def normalizeVideoTaskStatus(status):
+ normalized = str(status or "").strip().lower()
+ if normalized == "done":
+ return "completed"
+ if normalized == "expired":
+ return "failed"
+ return "processing"
+
+
+async def draw_image(params):
+ if not params or not params.get("prompt") or not str(params.get("prompt")).strip():
+ raise Error("参数 prompt 不能为空。")
+
+ prompt = str(params.get("prompt")).strip()
+ await ensureDirectories()
+
+ apiResult = await callXaiImageApi({
+ "prompt": prompt,
+ "model": params.get("model"),
+ "size": params.get("size"),
+ })
+
+ ext = guessExtensionFromUrl(apiResult["image_url"], "png")
+ baseName = buildFileName(prompt, params.get("file_name"), "xai_draw")
+ filePath = f"{DRAWS_DIR}/{baseName}.{ext}"
+ downloadResult = await Tools.Files.download(apiResult["image_url"], filePath)
+ if not downloadResult.successful:
+ raise Error(f"下载图片失败: {downloadResult.details}")
+
+ fileUri = f"file://{filePath}"
+ markdown = f""
+
+ hintLines = []
+ hintLines.append(f"图片已生成并保存在本地 {DRAWS_DIR}。")
+ hintLines.append(f"本地路径: {filePath}")
+ hintLines.append("")
+ hintLines.append("在后续回答中,请直接输出下面这一行 Markdown 来展示这张图片:")
+ hintLines.append("")
+ hintLines.append(markdown)
+
+ return {
+ "prompt": prompt,
+ "model": apiResult["effective_model"],
+ "remote_image_url": apiResult["image_url"],
+ "file_path": filePath,
+ "file_uri": fileUri,
+ "markdown": markdown,
+ "hint": "\n".join(hintLines),
+ }
+
+
+async def draw_video(params):
+ prompt = str(params.get("prompt") if params else "").strip()
+ if not prompt:
+ raise Error("prompt 不能为空。")
+
+ await ensureDirectories()
+
+ resolvedInputs = await resolveVideoInputs(params.get("image_url"), params.get("image_path"), params.get("video_url"))
+ mergedParams = dict(params)
+ mergedParams["prompt"] = prompt
+ mergedParams["image"] = resolvedInputs.get("image")
+ mergedParams["video_url"] = resolvedInputs.get("video_url")
+ createdTask = await createVideoTask(mergedParams)
+
+ pollIntervalMs = normalizePositiveInteger(params.get("poll_interval_ms"), DEFAULT_POLL_INTERVAL_MS)
+ maxWaitTimeMs = normalizePositiveInteger(params.get("max_wait_time_ms"), DEFAULT_MAX_WAIT_TIME_MS)
+ deadline = time.time() * 1000 + maxWaitTimeMs
+
+ latestStatus = createdTask["status"]
+ latestErrorMessage = ""
+ remoteVideoUrl = ""
+ remoteContentType = ""
+ expiresAt = ""
+ remoteDuration = createdTask["duration"]
+ remoteRespectModeration = ""
+
+ while time.time() * 1000 <= deadline:
+ statusResult = await queryVideoTaskStatus(createdTask["request_id"])
+ latestStatus = statusResult["status"]
+ latestErrorMessage = statusResult["error_message"]
+ remoteContentType = statusResult["content_type"]
+ expiresAt = statusResult["expires_at"]
+ remoteDuration = statusResult["duration"]
+ remoteRespectModeration = statusResult["respect_moderation"]
+
+ normalizedStatus = normalizeVideoTaskStatus(statusResult["status"])
+ if normalizedStatus == "completed":
+ remoteVideoUrl = statusResult["video_url"]
+ if not remoteVideoUrl:
+ raise Error("视频任务已完成,但响应中未找到 video.url。")
+ break
+
+ if normalizedStatus == "failed":
+ raise Error(f"视频生成失败或过期: {latestErrorMessage or latestStatus or '接口未返回失败原因'}")
+
+ await Tools.System.sleep(pollIntervalMs)
+
+ if not remoteVideoUrl:
+ raise Error(
+ f"视频生成超时,最后状态为 {latestStatus or '未知'}{(',原因:' + latestErrorMessage) if latestErrorMessage else ''}"
+ )
+
+ extension = guessExtensionFromUrl(remoteVideoUrl, "mp4")
+ baseName = buildFileName(prompt, params.get("file_name"), "xai_video")
+ filePath = f"{VIDEOS_DIR}/{baseName}.{extension}"
+ downloadResult = await Tools.Files.download(remoteVideoUrl, filePath)
+ if not downloadResult.successful:
+ raise Error(f"下载视频失败: {downloadResult.details}")
+
+ fileUri = f"file://{filePath}"
+ markdownLink = f"[点击查看生成的视频]({fileUri})"
+ htmlVideo = f''
+
+ hintLines = []
+ hintLines.append(f"视频已生成并保存在本地 {VIDEOS_DIR}。")
+ hintLines.append(f"本地路径: {filePath}")
+ hintLines.append("")
+ hintLines.append("后续回答如果要给出视频,请优先返回这个本地链接:")
+ hintLines.append(markdownLink)
+ hintLines.append("")
+ hintLines.append("如果当前渲染环境支持 HTML 视频标签,也可以使用:")
+ hintLines.append(htmlVideo)
+
+ return {
+ "prompt": prompt,
+ "model": createdTask["effective_model"],
+ "request_id": createdTask["request_id"],
+ "status": latestStatus,
+ "input_type": resolvedInputs["input_type"],
+ "aspect_ratio": createdTask["aspect_ratio"],
+ "resolution": createdTask["resolution"],
+ "duration": remoteDuration,
+ "remote_video_url": remoteVideoUrl,
+ "remote_content_type": remoteContentType,
+ "remote_expires_at": expiresAt,
+ "remote_respect_moderation": remoteRespectModeration,
+ "file_path": filePath,
+ "file_uri": fileUri,
+ "markdown_link": markdownLink,
+ "html_video": htmlVideo,
+ "hint": "\n".join(hintLines),
+ }
+
+
+async def draw_image_wrapper(params):
+ try:
+ result = await draw_image(params)
+ complete({
+ "success": True,
+ "message": "xAI 图片生成成功,已下载到本地。",
+ "data": result,
+ })
+ except Exception as error:
+ console.error("draw_image 执行失败:", error)
+ complete({
+ "success": False,
+ "message": f"xAI 图片生成失败: {getErrorMessage(error)}",
+ "error_stack": getErrorStack(error),
+ })
+
+
+async def draw_video_wrapper(params):
+ try:
+ result = await draw_video(params if params else {})
+ complete({
+ "success": True,
+ "message": "xAI 视频生成成功,已下载到本地。",
+ "data": result,
+ })
+ except Exception as error:
+ console.error("draw_video 执行失败:", error)
+ complete({
+ "success": False,
+ "message": f"xAI 视频生成失败: {getErrorMessage(error)}",
+ "error_stack": getErrorStack(error),
+ })
+
+
+exports.draw_image = draw_image_wrapper
+exports.draw_video = draw_video_wrapper
diff --git a/operit_py/zhipu_draw.py b/operit_py/zhipu_draw.py
new file mode 100644
index 0000000..3c40034
--- /dev/null
+++ b/operit_py/zhipu_draw.py
@@ -0,0 +1,223 @@
+# METADATA
+# {
+# "name": "zhipu_draw",
+# "display_name": {
+# "zh": "智谱生图",
+# "en": "Zhipu Draw"
+# },
+# "description": {
+# "zh": "使用智谱AI图像生成API根据提示词画图,将图片保存到本地 /sdcard/Download/Operit/plugins/draw/zhipu_draw/draws/ 目录,并返回 Markdown 图片提示。",
+# "en": "Generate images via Zhipu AI image generation API from a prompt, save to /sdcard/Download/Operit/plugins/draw/zhipu_draw/draws/, and return a Markdown image reference."
+# },
+# "env": [
+# {
+# "name": "ZHIPU_API_KEY",
+# "description": {
+# "zh": "智谱API Key(必填)",
+# "en": "Zhipu API key (required)"
+# },
+# "required": true
+# },
+# {
+# "name": "ZHIPU_IMAGE_MODEL",
+# "description": {
+# "zh": "默认绘图模型(可选;当 draw_image 未传 model 时使用)",
+# "en": "Default image model (optional; used when draw_image doesn't pass model)"
+# },
+# "required": false
+# }
+# ],
+# "category": "Draw",
+# "tools": [
+# {
+# "name": "draw_image",
+# "description": {
+# "zh": "根据提示词调用智谱AI图像生成接口生成图片,保存到本地并返回 Markdown 图片提示。",
+# "en": "Generate an image via Zhipu AI image generation API using a prompt, save it locally, and return a Markdown image reference."
+# },
+# "parameters": [
+# { "name": "prompt", "description": { "zh": "绘图提示词(中文或英文)", "en": "Prompt for image generation (Chinese or English)" }, "type": "string", "required": true },
+# { "name": "size", "description": { "zh": "图片尺寸,例如 '1024x1024' 或 '1280x1280'(可选,默认1024x1024)", "en": "Image size, e.g. '1024x1024' or '1280x1280' (optional, default 1024x1024)" }, "type": "string", "required": false },
+# { "name": "file_name", "description": { "zh": "自定义保存到本地的文件名(不含路径和扩展名)", "en": "Custom output file name (without path or extension)" }, "type": "string", "required": false },
+# { "name": "model", "description": { "zh": "模型名称(可选;不传则使用环境变量 ZHIPU_IMAGE_MODEL,否则默认glm-image)", "en": "Model name (optional; falls back to env ZHIPU_IMAGE_MODEL)" }, "type": "string", "required": false }
+# ]
+# }
+# ]
+# }
+
+import json
+import re
+import time
+import traceback
+
+HTTP_TIMEOUT_MS = 600000
+client = OkHttp.newBuilder()
+client = client.connectTimeout(HTTP_TIMEOUT_MS)
+client = client.readTimeout(HTTP_TIMEOUT_MS)
+client = client.writeTimeout(HTTP_TIMEOUT_MS)
+client = client.build()
+
+API_BASE_URL = "https://open.bigmodel.cn/api/paas/v4/images/generations"
+DRAW_ROOT_DIR = getPluginConfigDir("draw")
+STORAGE_DIR = f"{DRAW_ROOT_DIR}/zhipu_draw"
+DRAWS_DIR = f"{STORAGE_DIR}/draws"
+
+
+def getErrorMessage(error):
+ if isinstance(error, Exception):
+ return str(error)
+ return str(error)
+
+
+def getErrorStack(error):
+ if isinstance(error, Exception):
+ return traceback.format_exc()
+ return None
+
+
+def getApiKey():
+ apiKey = getEnv("ZHIPU_API_KEY")
+ if not apiKey:
+ raise Exception("ZHIPU_API_KEY 未配置,请在环境变量中设置智谱的 API Key。")
+ return apiKey
+
+
+def sanitizeFileName(name):
+ safe = re.sub(r'[\\/:*?"<>|]', '_', name).strip()
+ if not safe:
+ return f"zhipu_draw_{int(time.time() * 1000)}"
+ return safe[:80]
+
+
+def buildFileName(prompt, customName=None):
+ if customName and len(customName.strip()) > 0:
+ return sanitizeFileName(customName)
+ shortPrompt = (prompt[:40] + "...") if len(prompt) > 40 else prompt
+ base = sanitizeFileName(shortPrompt or "image")
+ timestamp = int(time.time() * 1000)
+ return f"{base}_{timestamp}"
+
+
+async def ensureDirectories():
+ dirs = [DRAW_ROOT_DIR, STORAGE_DIR, DRAWS_DIR]
+ for dir_ in dirs:
+ try:
+ result = await Tools.Files.mkdir(dir_)
+ if not result.get("successful"):
+ console.warn(f"创建目录失败(可能已存在): {dir_} -> {result.get('details')}")
+ except Exception as e:
+ console.warn(f"创建目录异常: {dir_} -> {getErrorMessage(e)}")
+
+
+async def callZhipuImageAPI(params):
+ apiKey = getApiKey()
+ modelFromParam = (params.get("model") or "").strip()
+ modelFromEnv = (getEnv("ZHIPU_IMAGE_MODEL") or "").strip()
+ effectiveModel = modelFromParam or modelFromEnv or "glm-image"
+
+ body = {
+ "model": effectiveModel,
+ "prompt": params["prompt"],
+ "size": params.get("size") or "1024x1024"
+ }
+
+ headers = {
+ "accept": "application/json",
+ "content-type": "application/json",
+ "Authorization": f"Bearer {apiKey}"
+ }
+
+ request = client.newRequest()
+ request = request.url(API_BASE_URL)
+ request = request.method("POST")
+ request = request.headers(headers)
+ request = request.body(json.dumps(body), "json")
+
+ response = await request.build().execute()
+
+ if not response.isSuccessful():
+ raise Exception(f"智谱图片 API 调用失败: {response.statusCode} - {response.content}")
+
+ try:
+ parsed = json.loads(response.content)
+ except Exception as e:
+ raise Exception(f"解析智谱响应失败: {getErrorMessage(e)}")
+
+ data = parsed.get("data") if isinstance(parsed, dict) else None
+ first = data[0] if (data and len(data) > 0) else None
+ if not first or not first.get("url"):
+ raise Exception("智谱响应中未找到有效的图片数据,请检查API返回格式")
+
+ result = {
+ "url": str(first["url"]),
+ "model": str(first["model"]) if first.get("model") else None
+ }
+
+ if not result["model"]:
+ result["model"] = effectiveModel
+
+ return result
+
+
+async def draw_image(params):
+ if not params or not params.get("prompt") or len(params.get("prompt", "").strip()) == 0:
+ raise Exception("参数 prompt 不能为空。")
+
+ prompt = params["prompt"].strip()
+ size = params.get("size") or "1024x1024"
+ model = params.get("model")
+
+ await ensureDirectories()
+
+ apiResult = await callZhipuImageAPI({"prompt": prompt, "size": size, "model": model})
+
+ if not apiResult["url"] or not apiResult["url"].strip():
+ raise Exception("API响应中未包含图片URL")
+
+ baseName = buildFileName(prompt, params.get("file_name"))
+ filePath = f"{DRAWS_DIR}/{baseName}.png"
+
+ downloadResult = await Tools.Files.download(apiResult["url"], filePath)
+ if not downloadResult.get("successful"):
+ raise Exception(f"下载图片失败: {downloadResult.get('details')}")
+
+ fileUri = f"file://{filePath}"
+ markdown = f""
+
+ hintLines = []
+ hintLines.append(f"图片已生成并保存在本地 {DRAWS_DIR}。")
+ hintLines.append(f"本地路径: {filePath}")
+ hintLines.append(f"提示词: {prompt}")
+ hintLines.append(f"模型: {apiResult.get('model') or 'glm-image'}")
+ hintLines.append("")
+ hintLines.append("在后续回答中,请直接输出下面这一行 Markdown 来展示这张图片:")
+ hintLines.append("")
+ hintLines.append(markdown)
+
+ return {
+ "file_path": filePath,
+ "file_uri": fileUri,
+ "markdown": markdown,
+ "prompt": prompt,
+ "hint": "\n".join(hintLines)
+ }
+
+
+async def draw_image_wrapper(params):
+ try:
+ result = await draw_image(params)
+ complete({
+ "success": True,
+ "message": f"图片生成成功,已保存到 {DRAWS_DIR},并返回 Markdown 图片提示。",
+ "data": result
+ })
+ except Exception as error:
+ console.error("draw_image 执行失败:", error)
+ complete({
+ "success": False,
+ "message": f"图片生成失败: {getErrorMessage(error)}",
+ "error_stack": getErrorStack(error)
+ })
+
+
+exports.draw_image = draw_image_wrapper
diff --git a/operit_py/zhipu_search.py b/operit_py/zhipu_search.py
new file mode 100644
index 0000000..c3ca48a
--- /dev/null
+++ b/operit_py/zhipu_search.py
@@ -0,0 +1,192 @@
+# METADATA
+# {
+# "name": "zhipu_search",
+# "display_name": {
+# "zh": "智谱搜索",
+# "en": "Zhipu Search"
+# },
+# "description": {
+# "zh": "智谱 AI 独立网络搜索 API,返回结构化搜索结果。",
+# "en": "Zhipu AI standalone web search API with structured results."
+# },
+# "author": "浮生一梦",
+# "env": [
+# {
+# "name": "ZHIPU_SEARCH_API_KEY",
+# "description": {
+# "zh": "智谱搜索专用 API Key(与生图 Key 独立)",
+# "en": "Zhipu Search API Key (independent from draw Key)"
+# },
+# "required": false
+# }
+# ],
+# "category": "Search",
+# "tools": [
+# {
+# "name": "search",
+# "description": {
+# "zh": "使用智谱 Web Search API 进行搜索",
+# "en": "Search using Zhipu Web Search API"
+# },
+# "parameters": [
+# { "name": "query", "description": { "zh": "搜索关键词", "en": "Search query" }, "type": "string", "required": true },
+# { "name": "api_key", "description": { "zh": "智谱 API Key(可选,不传则读取环境变量)", "en": "Zhipu API Key" }, "type": "string", "required": false },
+# { "name": "engine", "description": { "zh": "搜索引擎:search_std/search_pro/search_pro_sogou/search_pro_quark", "en": "Search engine" }, "type": "string", "required": false },
+# { "name": "count", "description": { "zh": "返回结果数 (1-50),默认 10", "en": "Result count (1-50)" }, "type": "number", "required": false },
+# { "name": "recency", "description": { "zh": "时间范围:oneDay/oneWeek/oneMonth/oneYear/noLimit", "en": "Time range" }, "type": "string", "required": false },
+# { "name": "content_size", "description": { "zh": "内容长度:medium/high", "en": "Content size" }, "type": "string", "required": false }
+# ]
+# },
+# {
+# "name": "test",
+# "description": {
+# "zh": "测试 API 连接",
+# "en": "Test API connection"
+# },
+# "parameters": []
+# }
+# ]
+# }
+
+import json
+import time
+import traceback
+
+API_URL = "https://open.bigmodel.cn/api/paas/v4/web_search"
+TIMEOUT = 60000
+client = OkHttp.newBuilder()
+client = client.connectTimeout(TIMEOUT)
+client = client.readTimeout(TIMEOUT)
+client = client.writeTimeout(TIMEOUT)
+client = client.build()
+
+
+def getApiKey(providedKey=None):
+ if providedKey:
+ return providedKey
+ key = getEnv("ZHIPU_SEARCH_API_KEY")
+ if key:
+ return key
+ key = getEnv("DEFAULT_API_KEY")
+ return key or ""
+
+
+async def httpPost(body, apiKey=None):
+ key = getApiKey(apiKey)
+ if not key:
+ raise Exception("未设置 API Key,请配置 ZHIPU_SEARCH_API_KEY 环境变量或在调用时传入 api_key 参数")
+
+ request = client.newRequest()
+ request = request.url(API_URL)
+ request = request.method("POST")
+ request = request.header("Authorization", "Bearer " + key)
+ request = request.header("Content-Type", "application/json")
+ request = request.body(json.dumps(body), "json")
+
+ response = await request.build().execute()
+ content = response.content
+
+ if not response.isSuccessful():
+ raise Exception("HTTP " + str(response.statusCode) + ": " + content)
+
+ return json.loads(content)
+
+
+async def search(params):
+ body = {
+ "search_query": params["query"],
+ "search_engine": params.get("engine") or "search_std",
+ "search_intent": True,
+ "count": params.get("count") or 10,
+ "content_size": params.get("content_size") or "medium"
+ }
+
+ if params.get("recency"):
+ body["search_recency_filter"] = params["recency"]
+
+ result = await httpPost(body, params.get("api_key"))
+
+ results = []
+ if result.get("search_result") and isinstance(result["search_result"], list):
+ results = [
+ {
+ "title": item.get("title"),
+ "content": item.get("content"),
+ "link": item.get("link"),
+ "media": item.get("media"),
+ "icon": item.get("icon"),
+ "publish_date": item.get("publish_date"),
+ "refer": item.get("refer")
+ }
+ for item in result["search_result"]
+ ]
+
+ intent = None
+ if result.get("search_intent") and len(result["search_intent"]) > 0:
+ intent = result["search_intent"][0] or None
+
+ return {
+ "success": True,
+ "query": params["query"],
+ "id": result.get("id"),
+ "intent": intent,
+ "results": results,
+ "count": len(results)
+ }
+
+
+async def test():
+ body = {
+ "search_query": "hi",
+ "search_engine": "search_std",
+ "search_intent": False,
+ "count": 1
+ }
+
+ start = time.time() * 1000
+ result = await httpPost(body, None)
+ latency = time.time() * 1000 - start
+
+ return {
+ "success": True,
+ "latency": latency,
+ "id": result.get("id")
+ }
+
+
+async def searchWrapper(params):
+ try:
+ result = await search(params)
+ complete({
+ "success": True,
+ "message": "搜索完成,找到 " + str(result["count"]) + " 条结果",
+ "data": result
+ })
+ except Exception as error:
+ msg = str(getattr(error, "message", error))
+ complete({
+ "success": False,
+ "message": "搜索失败:" + msg,
+ "error_stack": traceback.format_exc()
+ })
+
+
+async def testWrapper():
+ try:
+ result = await test()
+ complete({
+ "success": True,
+ "message": "连接成功,延迟 " + str(result["latency"]) + "ms",
+ "data": result
+ })
+ except Exception as error:
+ msg = str(getattr(error, "message", error))
+ complete({
+ "success": False,
+ "message": "测试失败:" + msg,
+ "error_stack": traceback.format_exc()
+ })
+
+
+exports.search = searchWrapper
+exports.test = testWrapper
diff --git a/operit_runtime.py b/operit_runtime.py
new file mode 100644
index 0000000..629257c
--- /dev/null
+++ b/operit_runtime.py
@@ -0,0 +1,1425 @@
+"""
+Operit Runtime for Python — 跨平台兼容层
+==========================================
+将 Operit AI 的 TypeScript 工具脚本 API 全面映射到 Python,
+使翻译后的 Python 脚本可在 Windows / macOS / Linux 上运行。
+
+核心映射:
+ complete(result) → 返回结果字典
+ exports → 模块级工具注册
+ OkHttp → requests/httpx 封装
+ Tools.System.* → OS 系统操作
+ Tools.Files.* → 文件系统操作
+ Tools.Net.* → 网络请求 + 浏览器自动化
+ Tools.UI.* → 桌面 UI 自动化 (pyautogui 降级)
+ Tools.Memory.* → 记忆库 (SQLite)
+ Tools.FFmpeg.* → FFmpeg 子进程
+ Tools.Workflow.* → 工作流引擎
+ Tools.Chat.* → 对话管理
+ Tools.SoftwareSettings.* → 配置管理
+ Tools.Tasker.* → 任务调度
+"""
+
+import os
+import sys
+import json
+import time
+import shutil
+import base64
+import hashlib
+import platform
+import subprocess
+import tempfile
+import asyncio
+import re
+import glob
+import zipfile
+import sqlite3
+import traceback
+import inspect
+from pathlib import Path
+from datetime import datetime, timezone, timedelta
+from typing import Any, Dict, List, Optional, Union, Callable
+
+# ── 第三方依赖(可选导入) ──
+try:
+ import requests
+except ImportError:
+ requests = None
+
+try:
+ import httpx
+except ImportError:
+ httpx = None
+
+try:
+ import pyautogui
+except ImportError:
+ pyautogui = None
+
+try:
+ from playwright.sync_api import sync_playwright
+ from playwright.async_api import async_playwright
+except ImportError:
+ sync_playwright = None
+ async_playwright = None
+
+
+# ═══════════════════════════════════════════════════════════════
+# 全局状态
+# ═══════════════════════════════════════════════════════════════
+
+# 临时目录(对应 Operit 的 OPERIT_CLEAN_ON_EXIT_DIR)
+OPERIT_CLEAN_ON_EXIT_DIR = tempfile.mkdtemp(prefix="operit_")
+
+# 结果回调
+_result_holder: Dict[str, Any] = {"result": None, "called": False}
+
+
+def complete(result: Any) -> None:
+ """Operit 的结果回调函数。Python 版直接存储结果。"""
+ _result_holder["result"] = result
+ _result_holder["called"] = True
+
+
+def _reset_result() -> None:
+ _result_holder["result"] = None
+ _result_holder["called"] = False
+
+
+def _get_result() -> Any:
+ return _result_holder["result"]
+
+
+# ═══════════════════════════════════════════════════════════════
+# Console 兼容
+# ═══════════════════════════════════════════════════════════════
+
+class _Console:
+ def log(self, *args, **kwargs):
+ print(*args, **kwargs)
+
+ def error(self, *args, **kwargs):
+ print(*args, file=sys.stderr, **kwargs)
+
+ def warn(self, *args, **kwargs):
+ print(*args, file=sys.stderr, **kwargs)
+
+ def info(self, *args, **kwargs):
+ print(*args, **kwargs)
+
+
+console = _Console()
+
+
+# ═══════════════════════════════════════════════════════════════
+# OkHttp 兼容层
+# ═══════════════════════════════════════════════════════════════
+
+class OkHttpResponse:
+ """模拟 Operit OkHttp 的 Response 对象"""
+
+ def __init__(self, status_code: int, content: str, headers: dict = None):
+ self.statusCode = status_code
+ self.content = content
+ self._headers = headers or {}
+
+ def isSuccessful(self) -> bool:
+ return 200 <= self.statusCode < 300
+
+ def headers(self) -> dict:
+ return self._headers
+
+ def header(self, name: str) -> str:
+ return self._headers.get(name, "")
+
+
+class OkHttpRequest:
+ """模拟 Operit OkHttp 的 Request 构建器"""
+
+ def __init__(self, client):
+ self._client = client
+ self._url = ""
+ self._method = "GET"
+ self._body_data = None
+ self._body_type = None
+ self._headers = {}
+
+ def url(self, url: str) -> "OkHttpRequest":
+ self._url = url
+ return self
+
+ def method(self, method: str) -> "OkHttpRequest":
+ self._method = method.upper()
+ return self
+
+ def body(self, data: Any, encoding: str = "json") -> "OkHttpRequest":
+ self._body_data = data
+ self._body_type = encoding
+ return self
+
+ def headers(self, headers: dict) -> "OkHttpRequest":
+ self._headers = headers
+ return self
+
+ def build(self) -> "OkHttpRequest":
+ return self
+
+ def execute(self) -> OkHttpResponse:
+ """同步执行请求"""
+ if requests is None and httpx is None:
+ raise RuntimeError("需要安装 requests 或 httpx: pip install requests")
+
+ lib = requests or httpx
+ kwargs = {"headers": self._headers}
+
+ if self._body_data is not None:
+ if self._body_type == "json":
+ kwargs["json"] = self._body_data
+ elif self._body_type == "form":
+ kwargs["data"] = self._body_data
+ else:
+ kwargs["data"] = self._body_data
+
+ if self._method == "GET":
+ resp = lib.get(self._url, **kwargs)
+ elif self._method == "POST":
+ resp = lib.post(self._url, **kwargs)
+ elif self._method == "PUT":
+ resp = lib.put(self._url, **kwargs)
+ elif self._method == "DELETE":
+ resp = lib.delete(self._url, **kwargs)
+ elif self._method == "PATCH":
+ resp = lib.patch(self._url, **kwargs)
+ else:
+ resp = lib.request(self._method, self._url, **kwargs)
+
+ return OkHttpResponse(
+ status_code=resp.status_code,
+ content=resp.text,
+ headers=dict(resp.headers)
+ )
+
+
+class OkHttpClient:
+ """模拟 Operit OkHttp 的 Client 对象"""
+
+ def __init__(self, base_url: str = ""):
+ self._base_url = base_url
+
+ def newRequest(self) -> OkHttpRequest:
+ return OkHttpRequest(self)
+
+ def newRequest(self) -> OkHttpRequest:
+ return OkHttpRequest(self)
+
+
+class OkHttpBuilder:
+ """模拟 Operit OkHttp.newBuilder() 链式构建器"""
+
+ def __init__(self):
+ self._base_url = ""
+
+ def baseUrl(self, url: str) -> "OkHttpBuilder":
+ self._base_url = url
+ return self
+
+ def build(self) -> OkHttpClient:
+ return OkHttpClient(self._base_url)
+
+
+class _OkHttp:
+ """OkHttp 全局对象"""
+
+ @staticmethod
+ def newBuilder() -> OkHttpBuilder:
+ return OkHttpBuilder()
+
+ @staticmethod
+ def newClient() -> OkHttpClient:
+ return OkHttpClient()
+
+
+OkHttp = _OkHttp()
+
+
+# ═══════════════════════════════════════════════════════════════
+# Tools.System
+# ═══════════════════════════════════════════════════════════════
+
+class _TerminalSession:
+ """终端会话管理"""
+
+ def __init__(self, name: str):
+ self.name = name
+ self.id = name
+ self._process = None
+ self._output = ""
+ self._env = os.environ.copy()
+
+ def exec(self, command: str, timeout: int = 30) -> dict:
+ """执行命令并返回结果"""
+ try:
+ result = subprocess.run(
+ command, shell=True, capture_output=True, text=True,
+ timeout=timeout, env=self._env
+ )
+ self._output = result.stdout + result.stderr
+ return {
+ "returnCode": result.returncode,
+ "output": self._output
+ }
+ except subprocess.TimeoutExpired:
+ return {"returnCode": -1, "output": "Command timed out"}
+ except Exception as e:
+ return {"returnCode": -1, "output": str(e)}
+
+ def hiddenExec(self, command: str, options: dict = None) -> dict:
+ """静默执行命令"""
+ timeout = (options or {}).get("timeout", 30)
+ return self.exec(command, timeout)
+
+ def screen(self) -> str:
+ """获取终端屏幕内容"""
+ return self._output
+
+ def input(self, params: dict) -> str:
+ """向终端输入"""
+ text = params.get("text", "")
+ key = params.get("key", "")
+ if text:
+ self._output += text
+ return self._output
+
+
+class _Terminal:
+ """Tools.System.terminal"""
+
+ def __init__(self):
+ self._sessions: Dict[str, _TerminalSession] = {}
+
+ def create(self, name: str) -> str:
+ session = _TerminalSession(name)
+ self._sessions[name] = session
+ return name
+
+ def exec(self, session_id: str, command: str, timeout: int = 30) -> dict:
+ session = self._sessions.get(session_id)
+ if not session:
+ return {"returnCode": -1, "output": f"Session {session_id} not found"}
+ return session.exec(command, timeout)
+
+ def hiddenExec(self, command: str, options: dict = None) -> dict:
+ session = _TerminalSession("hidden")
+ return session.hiddenExec(command, options)
+
+ def screen(self, session_id: str) -> str:
+ session = self._sessions.get(session_id)
+ if not session:
+ return ""
+ return session.screen()
+
+ def input(self, session_id: str, params: dict) -> str:
+ session = self._sessions.get(session_id)
+ if not session:
+ return ""
+ return session.input(params)
+
+
+class _Bluetooth:
+ """Tools.System.bluetooth — 桌面端降级实现"""
+
+ def requestPermission(self): return {"success": False, "message": "Bluetooth not supported on desktop"}
+ def getState(self): return {"success": False, "message": "Bluetooth not supported on desktop"}
+ def requestEnable(self): return {"success": False, "message": "Bluetooth not supported on desktop"}
+ def listBondedDevices(self): return {"success": False, "message": "Bluetooth not supported on desktop"}
+ def scan(self, params): return {"success": False, "message": "Bluetooth not supported on desktop"}
+ def connect(self, params): return {"success": False, "message": "Bluetooth not supported on desktop"}
+ def listen(self, params): return {"success": False, "message": "Bluetooth not supported on desktop"}
+ def accept(self, session_id, timeout): return {"success": False, "message": "Bluetooth not supported on desktop"}
+ def send(self, session_id, params): return {"success": False, "message": "Bluetooth not supported on desktop"}
+ def read(self, session_id, params): return {"success": False, "message": "Bluetooth not supported on desktop"}
+ def sendAndRead(self, session_id, params): return {"success": False, "message": "Bluetooth not supported on desktop"}
+ def close(self, session_id): return {"success": False, "message": "Bluetooth not supported on desktop"}
+
+ @property
+ def ble(self):
+ return _BluetoothBLE()
+
+
+class _BluetoothBLE:
+ """Tools.System.bluetooth.ble — 桌面端降级实现"""
+ def connect(self, params): return {"success": False, "message": "BLE not supported on desktop"}
+ def discoverServices(self, session_id, timeout): return {"success": False, "message": "BLE not supported on desktop"}
+ def readCharacteristic(self, session_id, params): return {"success": False, "message": "BLE not supported on desktop"}
+ def writeCharacteristic(self, session_id, params): return {"success": False, "message": "BLE not supported on desktop"}
+ def writeAndReadCharacteristic(self, session_id, params): return {"success": False, "message": "BLE not supported on desktop"}
+ def subscribe(self, session_id, params): return {"success": False, "message": "BLE not supported on desktop"}
+ def readNotifications(self, session_id, limit): return {"success": False, "message": "BLE not supported on desktop"}
+
+
+class _ToolsSystem:
+ """Tools.System — 跨平台系统操作"""
+
+ def __init__(self):
+ self.terminal = _Terminal()
+ self.bluetooth = _Bluetooth()
+
+ async def getDeviceInfo(self) -> dict:
+ return {
+ "model": platform.node(),
+ "os": f"{platform.system()} {platform.release()}",
+ "version": platform.version(),
+ "manufacturer": platform.machine(),
+ "platform": sys.platform,
+ "python": sys.version
+ }
+
+ async def sleep(self, ms: float) -> None:
+ await asyncio.sleep(ms / 1000.0)
+
+ async def shell(self, command: str) -> str:
+ """执行 shell 命令并返回输出"""
+ try:
+ result = subprocess.run(
+ command, shell=True, capture_output=True, text=True, timeout=60
+ )
+ return result.stdout + result.stderr
+ except Exception as e:
+ return str(e)
+
+ async def exec(self, command: str) -> str:
+ return await self.shell(command)
+
+ async def getSetting(self, name: str, namespace: str = "system") -> str:
+ """桌面端降级:返回空字符串"""
+ return ""
+
+ async def setSetting(self, name: str, value: str, namespace: str = "system") -> dict:
+ return {"success": False, "message": "System settings not supported on desktop"}
+
+ async def installApp(self, path: str) -> dict:
+ return {"success": False, "message": "App installation not supported on desktop"}
+
+ async def uninstallApp(self, package_name: str) -> dict:
+ return {"success": False, "message": "App uninstallation not supported on desktop"}
+
+ async def listApps(self, include_system: bool = False) -> list:
+ return []
+
+ async def startApp(self, package_name: str, activity: str = None) -> dict:
+ return {"success": False, "message": "App launch not supported on desktop"}
+
+ async def stopApp(self, package_name: str) -> dict:
+ return {"success": False, "message": "App stop not supported on desktop"}
+
+ async def sendBroadcast(self, params: dict) -> dict:
+ return {"success": False, "message": "Broadcast not supported on desktop"}
+
+ async def intent(self, params: dict) -> dict:
+ return {"success": False, "message": "Intent not supported on desktop"}
+
+ async def getNotifications(self, limit: int = 10, include_ongoing: bool = False) -> list:
+ return []
+
+ async def getAppUsageTime(self, params: dict) -> list:
+ return []
+
+ async def getLocation(self, high_accuracy: bool = False, timeout: int = 10) -> dict:
+ return {"success": False, "message": "Location not supported on desktop"}
+
+ async def usePackage(self, package_name: str) -> dict:
+ return {"success": False, "message": "Package system not supported on desktop"}
+
+
+# ═══════════════════════════════════════════════════════════════
+# Tools.Files
+# ═══════════════════════════════════════════════════════════════
+
+class _ToolsFiles:
+ """Tools.Files — 跨平台文件操作"""
+
+ async def read(self, path_or_params) -> dict:
+ """读取文件内容"""
+ if isinstance(path_or_params, dict):
+ path = path_or_params.get("path", "")
+ else:
+ path = path_or_params
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ content = f.read()
+ return {"success": True, "content": content}
+ except Exception as e:
+ return {"success": False, "message": str(e)}
+
+ async def write(self, path: str, content: str, append: bool = False) -> dict:
+ """写入文件"""
+ try:
+ mode = "a" if append else "w"
+ with open(path, mode, encoding="utf-8") as f:
+ f.write(content)
+ return {"success": True}
+ except Exception as e:
+ return {"success": False, "message": str(e)}
+
+ async def readBinary(self, path: str, environment: str = None) -> dict:
+ """读取二进制文件,返回 base64"""
+ try:
+ with open(path, "rb") as f:
+ data = f.read()
+ return {"success": True, "content": base64.b64encode(data).decode("utf-8")}
+ except Exception as e:
+ return {"success": False, "message": str(e)}
+
+ async def writeBinary(self, path: str, base64_data: str, environment: str = None) -> dict:
+ """写入二进制文件(从 base64)"""
+ try:
+ data = base64.b64decode(base64_data)
+ with open(path, "wb") as f:
+ f.write(data)
+ return {"success": True}
+ except Exception as e:
+ return {"success": False, "message": str(e)}
+
+ async def exists(self, path: str, environment: str = None) -> dict:
+ """检查文件/目录是否存在"""
+ return {"exists": os.path.exists(path), "success": True}
+
+ async def mkdir(self, path: str, recursive: bool = False, environment: str = None) -> dict:
+ """创建目录"""
+ try:
+ if recursive:
+ os.makedirs(path, exist_ok=True)
+ else:
+ os.mkdir(path)
+ return {"success": True}
+ except Exception as e:
+ return {"success": False, "message": str(e)}
+
+ async def deleteFile(self, path: str, recursive: bool = False, environment: str = None) -> dict:
+ """删除文件或目录"""
+ try:
+ if os.path.isdir(path):
+ if recursive:
+ shutil.rmtree(path)
+ else:
+ os.rmdir(path)
+ else:
+ os.remove(path)
+ return {"success": True}
+ except Exception as e:
+ return {"success": False, "message": str(e)}
+
+ async def list(self, path: str, environment: str = None) -> dict:
+ """列出目录内容"""
+ try:
+ entries = []
+ for entry in os.listdir(path):
+ full_path = os.path.join(path, entry)
+ entries.append({
+ "name": entry,
+ "path": full_path,
+ "isDirectory": os.path.isdir(full_path),
+ "size": os.path.getsize(full_path) if os.path.isfile(full_path) else 0
+ })
+ return {"success": True, "entries": entries}
+ except Exception as e:
+ return {"success": False, "message": str(e)}
+
+ async def find(self, directory: str, pattern: str) -> list:
+ """查找匹配模式的文件"""
+ if directory == "~":
+ directory = os.path.expanduser("~")
+ search_pattern = os.path.join(directory, "**", pattern)
+ return glob.glob(search_pattern, recursive=True)
+
+ async def move(self, source: str, destination: str, environment: str = None) -> dict:
+ """移动文件/目录"""
+ try:
+ shutil.move(source, destination)
+ return {"success": True}
+ except Exception as e:
+ return {"success": False, "message": str(e)}
+
+ async def copy(self, source: str, destination: str, recursive: bool = False,
+ source_env: str = None, dest_env: str = None) -> dict:
+ """复制文件/目录"""
+ try:
+ if os.path.isdir(source):
+ shutil.copytree(source, destination)
+ else:
+ shutil.copy2(source, destination)
+ return {"success": True}
+ except Exception as e:
+ return {"success": False, "message": str(e)}
+
+ async def info(self, path: str, environment: str = None) -> dict:
+ """获取文件信息"""
+ try:
+ stat = os.stat(path)
+ return {
+ "success": True,
+ "exists": True,
+ "size": stat.st_size,
+ "isDirectory": os.path.isdir(path),
+ "isFile": os.path.isfile(path),
+ "modified": stat.st_mtime,
+ "created": stat.st_ctime,
+ "path": path,
+ "name": os.path.basename(path)
+ }
+ except Exception as e:
+ return {"success": False, "message": str(e), "exists": False}
+
+ async def download(self, url: str, file_path: str) -> dict:
+ """下载文件"""
+ try:
+ lib = requests or (httpx if httpx else None)
+ if lib is None:
+ raise RuntimeError("需要 requests 或 httpx")
+ resp = lib.get(url, stream=True)
+ resp.raise_for_status()
+ os.makedirs(os.path.dirname(file_path), exist_ok=True) if os.path.dirname(file_path) else None
+ with open(file_path, "wb") as f:
+ for chunk in resp.iter_content(chunk_size=8192):
+ f.write(chunk)
+ return {"success": True, "path": file_path}
+ except Exception as e:
+ return {"success": False, "message": str(e)}
+
+ async def zip(self, source: str, destination: str, environment: str = None, include_root: bool = False) -> dict:
+ """压缩文件/目录"""
+ try:
+ with zipfile.ZipFile(destination, "w", zipfile.ZIP_DEFLATED) as zf:
+ if os.path.isdir(source):
+ for root, dirs, files in os.walk(source):
+ for file in files:
+ file_path = os.path.join(root, file)
+ arcname = os.path.relpath(file_path, source)
+ zf.write(file_path, arcname)
+ else:
+ zf.write(source, os.path.basename(source))
+ return {"success": True}
+ except Exception as e:
+ return {"success": False, "message": str(e)}
+
+ async def unzip(self, source: str, destination: str, environment: str = None) -> dict:
+ """解压文件"""
+ try:
+ with zipfile.ZipFile(source, "r") as zf:
+ zf.extractall(destination)
+ return {"success": True}
+ except Exception as e:
+ return {"success": False, "message": str(e)}
+
+ async def open(self, path: str, environment: str = None) -> dict:
+ """用系统默认程序打开文件"""
+ try:
+ if sys.platform == "win32":
+ os.startfile(path)
+ elif sys.platform == "darwin":
+ subprocess.run(["open", path])
+ else:
+ subprocess.run(["xdg-open", path])
+ return {"success": True}
+ except Exception as e:
+ return {"success": False, "message": str(e)}
+
+ async def share(self, path: str, title: str = "", environment: str = None) -> dict:
+ """分享文件(桌面端降级为打开)"""
+ return await self.open(path, environment)
+
+
+# ═══════════════════════════════════════════════════════════════
+# Tools.Net
+# ═══════════════════════════════════════════════════════════════
+
+class _ToolsNet:
+ """Tools.Net — 网络请求 + 浏览器自动化"""
+
+ async def visit(self, url_or_params) -> str:
+ """访问网页并返回内容"""
+ if isinstance(url_or_params, dict):
+ url = url_or_params.get("url", "")
+ else:
+ url = url_or_params
+ try:
+ lib = requests or (httpx if httpx else None)
+ if lib is None:
+ raise RuntimeError("需要 requests 或 httpx")
+ resp = lib.get(url, timeout=30, headers={
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
+ })
+ return resp.text
+ except Exception as e:
+ return str(e)
+
+ async def fetch(self, url: str, options: dict = None) -> dict:
+ """HTTP 请求"""
+ options = options or {}
+ method = options.get("method", "GET")
+ headers = options.get("headers", {})
+ body = options.get("body")
+ try:
+ lib = requests or (httpx if httpx else None)
+ if lib is None:
+ raise RuntimeError("需要 requests 或 httpx")
+ kwargs = {"headers": headers, "timeout": 30}
+ if body:
+ kwargs["data"] = body if isinstance(body, str) else json.dumps(body)
+ resp = lib.request(method, url, **kwargs)
+ return {
+ "ok": resp.ok if hasattr(resp, 'ok') else resp.is_success,
+ "status": resp.status_code,
+ "text": resp.text,
+ "headers": dict(resp.headers)
+ }
+ except Exception as e:
+ return {"ok": False, "status": 0, "text": str(e)}
+
+ async def uploadFile(self, params: dict) -> dict:
+ """上传文件"""
+ url = params.get("url", "")
+ file_path = params.get("file", params.get("path", ""))
+ field_name = params.get("field", "file")
+ extra_data = params.get("data", {})
+ headers = params.get("headers", {})
+ try:
+ lib = requests or (httpx if httpx else None)
+ if lib is None:
+ raise RuntimeError("需要 requests 或 httpx")
+ with open(file_path, "rb") as f:
+ resp = lib.post(url, files={field_name: f}, data=extra_data, headers=headers, timeout=60)
+ return {"success": True, "statusCode": resp.status_code, "content": resp.text}
+ except Exception as e:
+ return {"success": False, "message": str(e)}
+
+ # ── 浏览器自动化(Playwright 对齐)──
+
+ def _check_playwright(self):
+ if async_playwright is None:
+ raise RuntimeError("需要安装 Playwright: pip install playwright && playwright install")
+
+ async def browserNavigate(self, params: dict) -> str:
+ self._check_playwright()
+ # 实际实现需要维护一个浏览器实例池
+ # 这里提供接口,具体由 browser_auto 插件或 Playwright MCP 处理
+ return f"[browser] Navigate to {params.get('url', '')}"
+
+ async def browserClick(self, params: dict) -> str:
+ return f"[browser] Click {params}"
+
+ async def browserClose(self) -> str:
+ return "[browser] Closed"
+
+ async def browserCloseAll(self) -> str:
+ return "[browser] All closed"
+
+ async def browserSnapshot(self, params: dict = None) -> str:
+ return "[browser] Snapshot"
+
+ async def browserType(self, params: dict) -> str:
+ return f"[browser] Type {params}"
+
+ async def browserPressKey(self, params: dict) -> str:
+ return f"[browser] Press {params.get('key', '')}"
+
+ async def browserFillForm(self, params: dict) -> str:
+ return f"[browser] Fill form"
+
+ async def browserHover(self, params: dict) -> str:
+ return f"[browser] Hover"
+
+ async def browserDrag(self, params: dict) -> str:
+ return f"[browser] Drag"
+
+ async def browserEvaluate(self, params: dict) -> str:
+ return f"[browser] Evaluate"
+
+ async def browserConsoleMessages(self, params: dict = None) -> str:
+ return "[browser] Console messages"
+
+ async def browserNetworkRequests(self, params: dict = None) -> str:
+ return "[browser] Network requests"
+
+ async def browserResize(self, params: dict) -> str:
+ return f"[browser] Resize {params.get('width', 0)}x{params.get('height', 0)}"
+
+ async def browserSelectOption(self, params: dict) -> str:
+ return f"[browser] Select option"
+
+ async def browserHandleDialog(self, params: dict) -> str:
+ return f"[browser] Handle dialog"
+
+ async def browserFileUpload(self, params: dict) -> str:
+ return f"[browser] File upload"
+
+ async def browserWaitFor(self, params: dict) -> str:
+ return "[browser] Wait complete"
+
+ async def browserRunCode(self, params: dict) -> str:
+ return f"[browser] Run code"
+
+ async def browserNavigateBack(self) -> str:
+ return "[browser] Navigate back"
+
+ async def browserTabs(self, params: dict) -> str:
+ return f"[browser] Tabs {params.get('action', '')}"
+
+
+# ═══════════════════════════════════════════════════════════════
+# Tools.UI — 桌面 UI 自动化(pyautogui 降级)
+# ═══════════════════════════════════════════════════════════════
+
+class _ToolsUI:
+ """Tools.UI — 桌面端 UI 自动化"""
+
+ async def tap(self, x: int, y: int) -> dict:
+ if pyautogui:
+ pyautogui.click(x, y)
+ return {"success": True}
+ return {"success": False, "message": "pyautogui not installed"}
+
+ async def longPress(self, x: int, y: int) -> dict:
+ if pyautogui:
+ pyautogui.rightClick(x, y)
+ return {"success": True}
+ return {"success": False, "message": "pyautogui not installed"}
+
+ async def clickElement(self, params: dict) -> dict:
+ return {"success": False, "message": "clickElement not supported on desktop"}
+
+ async def setText(self, text: str) -> dict:
+ if pyautogui:
+ pyautogui.typewrite(text)
+ return {"success": True}
+ return {"success": False, "message": "pyautogui not installed"}
+
+ async def pressKey(self, key_code: str) -> dict:
+ if pyautogui:
+ key_map = {
+ "KEYCODE_BACK": "escape",
+ "KEYCODE_ENTER": "enter",
+ "KEYCODE_HOME": "win",
+ "KEYCODE_TAB": "tab",
+ "KEYCODE_ESCAPE": "escape",
+ }
+ pyautogui.press(key_map.get(key_code, key_code.lower()))
+ return {"success": True}
+ return {"success": False, "message": "pyautogui not installed"}
+
+ async def swipe(self, start_x: int, start_y: int, end_x: int, end_y: int) -> dict:
+ if pyautogui:
+ pyautogui.moveTo(start_x, start_y)
+ pyautogui.dragTo(end_x, end_y, duration=0.5)
+ return {"success": True}
+ return {"success": False, "message": "pyautogui not installed"}
+
+ async def getPageInfo(self) -> dict:
+ return {"success": False, "message": "getPageInfo not supported on desktop", "activity": ""}
+
+ async def runSubAgent(self, intent: str, max_steps: int = 10,
+ agent_id: str = None, target_app: str = None) -> dict:
+ return {"success": False, "message": "UI SubAgent not supported on desktop"}
+
+
+# ═══════════════════════════════════════════════════════════════
+# Tools.Memory — SQLite 记忆库
+# ═══════════════════════════════════════════════════════════════
+
+class _ToolsMemory:
+ """Tools.Memory — SQLite 持久化记忆"""
+
+ def __init__(self):
+ self._db_path = os.path.join(tempfile.gettempdir(), "operit_memory.db")
+ self._init_db()
+
+ def _init_db(self):
+ conn = sqlite3.connect(self._db_path)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS memories (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT UNIQUE,
+ content TEXT,
+ category TEXT,
+ created_at TEXT,
+ updated_at TEXT
+ )
+ """)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS links (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ source_title TEXT,
+ target_title TEXT,
+ relation TEXT
+ )
+ """)
+ conn.commit()
+ conn.close()
+
+ async def create(self, params: dict) -> dict:
+ try:
+ conn = sqlite3.connect(self._db_path)
+ now = datetime.now().isoformat()
+ conn.execute(
+ "INSERT OR REPLACE INTO memories (title, content, category, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
+ (params.get("title", ""), params.get("content", ""),
+ params.get("category", ""), now, now)
+ )
+ conn.commit()
+ conn.close()
+ return {"success": True}
+ except Exception as e:
+ return {"success": False, "message": str(e)}
+
+ async def update(self, old_title: str, updates: dict) -> dict:
+ try:
+ conn = sqlite3.connect(self._db_path)
+ now = datetime.now().isoformat()
+ for key, value in updates.items():
+ if key in ("content", "category", "title"):
+ conn.execute(f"UPDATE memories SET {key}=?, updated_at=? WHERE title=?", (value, now, old_title))
+ conn.commit()
+ conn.close()
+ return {"success": True}
+ except Exception as e:
+ return {"success": False, "message": str(e)}
+
+ async def deleteMemory(self, params: dict) -> dict:
+ try:
+ conn = sqlite3.connect(self._db_path)
+ conn.execute("DELETE FROM memories WHERE title=?", (params.get("title", ""),))
+ conn.commit()
+ conn.close()
+ return {"success": True}
+ except Exception as e:
+ return {"success": False, "message": str(e)}
+
+ async def move(self, params: dict) -> dict:
+ return await self.update(params.get("from", ""), {"title": params.get("to", "")})
+
+ async def link(self, params: dict) -> dict:
+ try:
+ conn = sqlite3.connect(self._db_path)
+ conn.execute(
+ "INSERT INTO links (source_title, target_title, relation) VALUES (?, ?, ?)",
+ (params.get("source", ""), params.get("target", ""), params.get("relation", ""))
+ )
+ conn.commit()
+ conn.close()
+ return {"success": True}
+ except Exception as e:
+ return {"success": False, "message": str(e)}
+
+ async def queryLinks(self, params: dict) -> list:
+ try:
+ conn = sqlite3.connect(self._db_path)
+ cursor = conn.execute(
+ "SELECT * FROM links WHERE source_title=? OR target_title=?",
+ (params.get("source", ""), params.get("target", ""))
+ )
+ results = [{"id": r[0], "source": r[1], "target": r[2], "relation": r[3]} for r in cursor.fetchall()]
+ conn.close()
+ return results
+ except Exception:
+ return []
+
+ async def updateLink(self, params: dict) -> dict:
+ return {"success": True}
+
+ async def deleteLink(self, params: dict) -> dict:
+ try:
+ conn = sqlite3.connect(self._db_path)
+ conn.execute("DELETE FROM links WHERE id=?", (params.get("id", 0),))
+ conn.commit()
+ conn.close()
+ return {"success": True}
+ except Exception as e:
+ return {"success": False, "message": str(e)}
+
+
+# ═══════════════════════════════════════════════════════════════
+# Tools.FFmpeg
+# ═══════════════════════════════════════════════════════════════
+
+class _ToolsFFmpeg:
+ """Tools.FFmpeg — FFmpeg 子进程封装"""
+
+ async def execute(self, command: str) -> dict:
+ try:
+ result = subprocess.run(
+ f"ffmpeg {command}", shell=True, capture_output=True, text=True, timeout=300
+ )
+ return {"returnCode": result.returncode, "output": result.stdout + result.stderr}
+ except Exception as e:
+ return {"returnCode": -1, "output": str(e)}
+
+ async def info(self) -> dict:
+ try:
+ result = subprocess.run(
+ "ffmpeg -version", shell=True, capture_output=True, text=True, timeout=10
+ )
+ return {"returnCode": result.returncode, "output": result.stdout + result.stderr}
+ except Exception as e:
+ return {"returnCode": -1, "output": str(e)}
+
+ async def convert(self, input_path: str, output_path: str, opts: dict = None) -> dict:
+ opts = opts or {}
+ cmd_parts = ["ffmpeg", "-y"]
+ if opts.get("video_codec"):
+ cmd_parts.extend(["-c:v", opts["video_codec"]])
+ if opts.get("audio_codec"):
+ cmd_parts.extend(["-c:a", opts["audio_codec"]])
+ if opts.get("resolution"):
+ cmd_parts.extend(["-s", opts["resolution"]])
+ if opts.get("bitrate"):
+ cmd_parts.extend(["-b", opts["bitrate"]])
+ cmd_parts.extend(["-i", input_path, output_path])
+ try:
+ result = subprocess.run(" ".join(cmd_parts), shell=True, capture_output=True, text=True, timeout=300)
+ return {"returnCode": result.returncode, "output": result.stdout + result.stderr}
+ except Exception as e:
+ return {"returnCode": -1, "output": str(e)}
+
+
+# ═══════════════════════════════════════════════════════════════
+# Tools.Workflow
+# ═══════════════════════════════════════════════════════════════
+
+class _ToolsWorkflow:
+ """Tools.Workflow — 工作流引擎"""
+
+ def __init__(self):
+ self._workflows: Dict[str, dict] = {}
+ self._next_id = 1
+
+ async def getAll(self) -> list:
+ return list(self._workflows.values())
+
+ async def create(self, name: str, description: str = "", nodes=None, edges=None, enabled: bool = False) -> dict:
+ wf_id = str(self._next_id)
+ self._next_id += 1
+ wf = {
+ "id": wf_id, "name": name, "description": description,
+ "nodes": nodes or [], "edges": edges or [],
+ "enabled": enabled, "created_at": datetime.now().isoformat()
+ }
+ self._workflows[wf_id] = wf
+ return wf
+
+ async def get(self, workflow_id: str) -> dict:
+ return self._workflows.get(workflow_id, {"success": False, "message": "Not found"})
+
+ async def update(self, workflow_id: str, updates: dict) -> dict:
+ if workflow_id in self._workflows:
+ self._workflows[workflow_id].update(updates)
+ return self._workflows[workflow_id]
+ return {"success": False, "message": "Not found"}
+
+ async def patch(self, workflow_id: str, patch: dict) -> dict:
+ return await self.update(workflow_id, patch)
+
+ async def enable(self, workflow_id: str) -> dict:
+ return await self.update(workflow_id, {"enabled": True})
+
+ async def disable(self, workflow_id: str) -> dict:
+ return await self.update(workflow_id, {"enabled": False})
+
+ async def delete(self, workflow_id: str) -> dict:
+ if workflow_id in self._workflows:
+ del self._workflows[workflow_id]
+ return {"success": True}
+ return {"success": False, "message": "Not found"}
+
+ async def trigger(self, workflow_id: str) -> dict:
+ wf = self._workflows.get(workflow_id)
+ if wf:
+ return {"success": True, "message": f"Workflow '{wf['name']}' triggered"}
+ return {"success": False, "message": "Not found"}
+
+
+# ═══════════════════════════════════════════════════════════════
+# Tools.Chat
+# ═══════════════════════════════════════════════════════════════
+
+class _ToolsChat:
+ """Tools.Chat — 对话管理(桌面端降级)"""
+
+ async def listChats(self, params: dict = None) -> list:
+ return []
+
+ async def findChat(self, params: dict) -> dict:
+ return {"success": False, "message": "Chat not found"}
+
+ async def getMessages(self, chat_id: str, opts: dict = None) -> list:
+ return []
+
+ async def updateTitle(self, chat_id: str, new_title: str) -> dict:
+ return {"success": False, "message": "Not supported on desktop"}
+
+ async def deleteChat(self, chat_id: str) -> dict:
+ return {"success": False, "message": "Not supported on desktop"}
+
+ async def agentStatus(self, chat_id: str) -> dict:
+ return {"success": False, "message": "Not supported on desktop"}
+
+ async def listCharacterCards(self) -> list:
+ return []
+
+ async def startService(self) -> dict:
+ return {"success": False, "message": "Not supported on desktop"}
+
+ async def createNew(self, *args) -> dict:
+ return {"success": False, "message": "Not supported on desktop"}
+
+ async def sendMessage(self, *args) -> dict:
+ return {"success": False, "message": "Not supported on desktop"}
+
+
+# ═══════════════════════════════════════════════════════════════
+# Tools.SoftwareSettings
+# ═══════════════════════════════════════════════════════════════
+
+class _ToolsSoftwareSettings:
+ """Tools.SoftwareSettings — 配置管理(桌面端降级)"""
+
+ def __init__(self):
+ self._config: Dict[str, Any] = {}
+ self._env_vars: Dict[str, str] = {}
+ self._model_configs: Dict[str, dict] = {}
+ self._sandbox_packages: Dict[str, bool] = {}
+
+ async def listSandboxPackages(self) -> list:
+ return [{"packageName": k, "enabled": v} for k, v in self._sandbox_packages.items()]
+
+ async def setSandboxPackageEnabled(self, package_name: str, enabled: bool) -> dict:
+ self._sandbox_packages[package_name] = enabled
+ return {"success": True}
+
+ async def getFunctionModelConfig(self, function_type: str) -> dict:
+ return self._model_configs.get(function_type, {"success": False, "message": "Not configured"})
+
+ async def setFunctionModelConfig(self, *args) -> dict:
+ return {"success": True}
+
+ async def listModelConfigs(self) -> list:
+ return list(self._model_configs.values())
+
+ async def createModelConfig(self, options: dict) -> dict:
+ config_id = str(len(self._model_configs) + 1)
+ self._model_configs[config_id] = {"id": config_id, **options}
+ return {"success": True, "id": config_id}
+
+ async def updateModelConfig(self, config_id: str, updates: dict) -> dict:
+ if config_id in self._model_configs:
+ self._model_configs[config_id].update(updates)
+ return {"success": True}
+ return {"success": False, "message": "Not found"}
+
+ async def deleteModelConfig(self, config_id: str) -> dict:
+ self._model_configs.pop(config_id, None)
+ return {"success": True}
+
+ async def listFunctionModelConfigs(self) -> list:
+ return []
+
+ async def testModelConfigConnection(self, config_id: str, model_index: int = None) -> dict:
+ return {"success": False, "message": "Not supported on desktop"}
+
+ async def readEnvironmentVariable(self, key: str) -> str:
+ return self._env_vars.get(key, os.environ.get(key, ""))
+
+ async def writeEnvironmentVariable(self, key: str, value: str) -> dict:
+ self._env_vars[key] = value
+ return {"success": True}
+
+ async def restartMcpWithLogs(self, timeout_ms: int = 30000) -> dict:
+ return {"success": False, "message": "Not supported on desktop"}
+
+ async def getSpeechServicesConfig(self) -> dict:
+ return {}
+
+ async def setSpeechServicesConfig(self, updates: dict) -> dict:
+ return {"success": True}
+
+ async def testTtsPlayback(self, text: str, options: dict = None) -> dict:
+ return {"success": False, "message": "TTS not supported on desktop"}
+
+ async def executeSandboxScriptDirect(self, params: dict) -> dict:
+ return {"success": False, "message": "Sandbox not supported on desktop"}
+
+
+# ═══════════════════════════════════════════════════════════════
+# Tools.Tasker
+# ═══════════════════════════════════════════════════════════════
+
+class _ToolsTasker:
+ """Tools.Tasker — Tasker 集成(桌面端降级)"""
+
+ async def triggerEvent(self, params: dict) -> dict:
+ return {"success": False, "message": "Tasker not supported on desktop"}
+
+
+# ═══════════════════════════════════════════════════════════════
+# 组装 Tools 全局对象
+# ═══════════════════════════════════════════════════════════════
+
+class _Tools:
+ """Tools 全局对象 — 包含所有子模块"""
+
+ def __init__(self):
+ self.System = _ToolsSystem()
+ self.Files = _ToolsFiles()
+ self.Net = _ToolsNet()
+ self.UI = _ToolsUI()
+ self.Memory = _ToolsMemory()
+ self.FFmpeg = _ToolsFFmpeg()
+ self.Workflow = _ToolsWorkflow()
+ self.Chat = _ToolsChat()
+ self.SoftwareSettings = _ToolsSoftwareSettings()
+ self.Tasker = _ToolsTasker()
+
+
+Tools = _Tools()
+
+
+# ═══════════════════════════════════════════════════════════════
+# Exports 容器
+# ═══════════════════════════════════════════════════════════════
+
+class Exports:
+ """模拟 CommonJS exports 对象"""
+
+ def __init__(self):
+ self._items: Dict[str, Any] = {}
+
+ def __setattr__(self, name, value):
+ if name.startswith("_"):
+ super().__setattr__(name, value)
+ else:
+ self._items[name] = value
+
+ def __getattr__(self, name):
+ if name.startswith("_"):
+ return super().__getattribute__(name)
+ return self._items.get(name)
+
+ def get(self, name: str, default=None):
+ return self._items.get(name, default)
+
+ def items(self) -> Dict[str, Any]:
+ return self._items
+
+ def keys(self):
+ return self._items.keys()
+
+
+# ═══════════════════════════════════════════════════════════════
+# 脚本加载器
+# ═══════════════════════════════════════════════════════════════
+
+class OperitScriptLoader:
+ """加载和执行 Python 版 Operit 脚本"""
+
+ def __init__(self, scripts_dir: str = None):
+ self.scripts_dir = scripts_dir or os.path.join(
+ os.path.dirname(__file__), "operit_py"
+ )
+ self._loaded_tools: Dict[str, dict] = {}
+
+ def load_script(self, script_path: str) -> dict:
+ """加载单个脚本,返回其 METADATA 和导出的工具"""
+ # 构建脚本的全局环境
+ exports = Exports()
+ script_globals = {
+ # Operit 全局 API
+ "complete": complete,
+ "exports": exports,
+ "console": console,
+ "OkHttp": OkHttp,
+ "Tools": Tools,
+ "OPERIT_CLEAN_ON_EXIT_DIR": OPERIT_CLEAN_ON_EXIT_DIR,
+ # Python 标准库
+ "os": os, "sys": sys, "json": json, "re": re, "time": time,
+ "datetime": datetime, "base64": base64, "hashlib": hashlib,
+ "subprocess": subprocess, "asyncio": asyncio,
+ "requests": requests, "httpx": httpx,
+ }
+
+ try:
+ with open(script_path, "r", encoding="utf-8") as f:
+ code = f.read()
+ exec(compile(code, script_path, "exec"), script_globals)
+ except Exception as e:
+ return {"success": False, "message": f"加载失败: {e}", "traceback": traceback.format_exc()}
+
+ # 提取 METADATA
+ metadata = self._extract_metadata(script_path, code)
+
+ # 提取导出的工具
+ tools = {}
+ for name, func in exports.items().items():
+ if callable(func):
+ tools[name] = {
+ "name": name,
+ "func": func,
+ "metadata": metadata
+ }
+
+ return {
+ "success": True,
+ "metadata": metadata,
+ "tools": tools
+ }
+
+ def _extract_metadata(self, script_path: str, code: str) -> dict:
+ """从脚本注释中提取 METADATA"""
+ # 匹配 Python 版 METADATA 注释
+ # 格式: # METADATA { ... } 或 '''METADATA { ... }'''
+ patterns = [
+ r'#\s*METADATA\s*(\{.*?\})\s*#', # 单行注释
+ r'#\s*METADATA\s*\n(.*?)(?=\n#(?!\s)|\n\ndef |\Z)', # 多行注释
+ r"'''\s*METADATA\s*(\{.*?\})\s*'''", # 三引号
+ r'"""\s*METADATA\s*(\{.*?\})\s*"""', # 双引号三引号
+ ]
+ for pattern in patterns:
+ match = re.search(pattern, code, re.DOTALL)
+ if match:
+ try:
+ return json.loads(match.group(1))
+ except json.JSONDecodeError:
+ continue
+
+ # 从文件名推断
+ return {
+ "name": Path(script_path).stem,
+ "display_name": {"zh": Path(script_path).stem, "en": Path(script_path).stem},
+ "description": "",
+ "category": "Utility",
+ "tools": []
+ }
+
+ def scan_directory(self, directory: str = None) -> dict:
+ """扫描目录,加载所有 .py 脚本"""
+ directory = directory or self.scripts_dir
+ if not os.path.isdir(directory):
+ return {"success": False, "message": f"目录不存在: {directory}"}
+
+ all_tools = {}
+ script_count = 0
+
+ for py_file in sorted(glob.glob(os.path.join(directory, "*.py"))):
+ if py_file.endswith("__init__.py"):
+ continue
+ result = self.load_script(py_file)
+ if result.get("success"):
+ script_count += 1
+ for tool_name, tool_info in result["tools"].items():
+ all_tools[tool_name] = tool_info
+ meta = result.get("metadata", {})
+ all_tools[tool_name]["script"] = Path(py_file).stem
+ all_tools[tool_name]["metadata"] = meta
+
+ self._loaded_tools = all_tools
+ return {
+ "success": True,
+ "scripts": script_count,
+ "tools": len(all_tools),
+ "tool_list": list(all_tools.keys())
+ }
+
+ def call_tool(self, tool_name: str, params: dict = None) -> Any:
+ """调用已加载的工具"""
+ params = params or {}
+ tool = self._loaded_tools.get(tool_name)
+ if not tool:
+ return {"success": False, "message": f"工具 '{tool_name}' 未找到"}
+
+ func = tool["func"]
+ _reset_result()
+
+ try:
+ # 检查函数签名,决定是否传参
+ sig = inspect.signature(func)
+ param_count = len([p for p in sig.parameters.values()
+ if p.kind in (p.POSITIONAL_OR_KEYWORD, p.POSITIONAL_ONLY)])
+
+ # 检查是否是协程函数
+ if asyncio.iscoroutinefunction(func):
+ if param_count > 0:
+ result = asyncio.run(func(params))
+ else:
+ result = asyncio.run(func())
+ else:
+ if param_count > 0:
+ result = func(params)
+ else:
+ result = func()
+
+ # 如果调用了 complete(),返回其结果
+ if _result_holder["called"]:
+ return _get_result()
+ # 否则返回函数返回值
+ return result
+ except Exception as e:
+ return {
+ "success": False,
+ "message": f"执行错误: {e}",
+ "traceback": traceback.format_exc()
+ }
+
+ def list_tools(self) -> list:
+ """列出所有已加载的工具"""
+ return [
+ {
+ "name": name,
+ "script": info.get("script", ""),
+ "category": info.get("metadata", {}).get("category", ""),
+ "description": info.get("metadata", {}).get("description", "")
+ }
+ for name, info in self._loaded_tools.items()
+ ]
+
+
+# ═══════════════════════════════════════════════════════════════
+# 便捷函数
+# ═══════════════════════════════════════════════════════════════
+
+def create_exports() -> Exports:
+ """创建一个新的 exports 对象"""
+ return Exports()
+
+
+def wrap_tool(core_func: Callable, success_msg: str = "操作成功", fail_msg: str = "操作失败") -> Callable:
+ """
+ Operit Wrapper 模式的 Python 实现
+ 自动处理 try/except 和 complete() 调用
+ """
+ async def wrapper(params: dict) -> None:
+ try:
+ result = await core_func(params) if asyncio.iscoroutinefunction(core_func) else core_func(params)
+ complete({"success": True, "message": success_msg, "data": result})
+ except Exception as e:
+ complete({"success": False, "message": f"{fail_msg}: {e}", "error_stack": traceback.format_exc()})
+
+ return wrapper
+
+
+# ═══════════════════════════════════════════════════════════════
+# 测试入口
+# ═══════════════════════════════════════════════════════════════
+
+if __name__ == "__main__":
+ loader = OperitScriptLoader()
+ print(f"Operit Runtime 初始化完成")
+ print(f" 临时目录: {OPERIT_CLEAN_ON_EXIT_DIR}")
+ print(f" 脚本目录: {loader.scripts_dir}")
+ print(f" 平台: {platform.system()} {platform.release()}")
+ print(f" Python: {sys.version.split()[0]}")
+
+ # 如果脚本目录存在,扫描加载
+ if os.path.isdir(loader.scripts_dir):
+ result = loader.scan_directory()
+ print(f" 已加载: {result.get('scripts', 0)} 个脚本, {result.get('tools', 0)} 个工具")
+ else:
+ print(f" 脚本目录不存在,请放入 .py 脚本")