Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feat](AI) add several AI related features #901

Merged
merged 7 commits into from
Feb 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ jobs:
pip install --upgrade pip
pip install pyqt5
pip install "pytest<7.2"
pip install -e .[dev,cookies,webserver,ytdl]
pip install -e .[dev,cookies,webserver,ytdl,ai]

- name: Install Python(macOS) Dependencies
if: startsWith(matrix.os, 'macos')
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/macos-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
python -m pip install --upgrade pip
pip install pyqt5
pip install pyinstaller
pip install -e .[macos,battery,cookies,ytdl]
pip install -e .[macos,battery,cookies,ytdl,ai]
- name: Install libmpv
run: |
brew install mpv
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/win-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
pip install pyqt5
pip install pyinstaller
pip install pyinstaller-versionfile
pip install -e .[win32,battery,cookies,ytdl]
pip install -e .[win32,battery,cookies,ytdl,ai]
- name: Download mpv-1.dll
run: |
choco install curl
Expand Down
37 changes: 37 additions & 0 deletions feeluown/ai.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import asyncio
import socket

from openai import AsyncOpenAI

from feeluown.utils.aio import run_afn


async def a_handle_stream(stream):
rsock, wsock = socket.socketpair()
rr, rw = await asyncio.open_connection(sock=rsock)
_, ww = await asyncio.open_connection(sock=wsock)

async def write_task():
async for chunk in stream:
content = chunk.choices[0].delta.content or ''
ww.write(content.encode('utf-8'))
ww.write_eof()
await ww.drain()
ww.close()
await ww.wait_closed()

task = run_afn(write_task)
return rr, rw, task


class AI:
def __init__(self, base_url, api_key, model):
self.base_url = base_url
self.api_key = api_key
self.model = model

def get_async_client(self):
return AsyncOpenAI(
base_url=self.base_url,
api_key=self.api_key,
)
25 changes: 22 additions & 3 deletions feeluown/app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@

from .mode import AppMode


logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -51,9 +50,29 @@ def __init__(self, args, config, **kwargs):
self.request = Request() # TODO: rename request to http
self.version_mgr = VersionManager(self)
self.task_mgr = TaskManager(self)

# Library.
self.library = Library(config.PROVIDERS_STANDBY)
self.library = Library(
config.PROVIDERS_STANDBY,
config.ENABLE_AI_STANDBY_MATCHER
)
self.ai = None
try:
from feeluown.ai import AI
except ImportError as e:
logger.warning(f"AI is not available, err: {e}")
else:
if (config.OPENAI_API_BASEURL and
config.OPENAI_API_KEY and
config.OPENAI_MODEL):
self.ai = AI(
config.OPENAI_API_BASEURL,
config.OPENAI_API_KEY,
config.OPENAI_MODEL,
)
self.library.setup_ai(self.ai)
else:
logger.warning("AI is not available, no valid settings")

if config.ENABLE_YTDL_AS_MEDIA_PROVIDER:
try:
self.library.setup_ytdl(rules=config.YTDL_RULES)
Expand Down
3 changes: 2 additions & 1 deletion feeluown/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ def create_config() -> Config:
)
config.deffield('OPENAI_API_KEY', type_=str, default='', desc='OpenAI API key')
config.deffield('OPENAI_MODEL', type_=str, default='', desc='OpenAI model name')
config.deffield('ENABLE_AI_STANDBY_MATCHER', type_=bool, default=True, desc='')
config.deffield(
'AI_RADIO_PROMPT',
type_=str,
Expand All @@ -96,7 +97,7 @@ def create_config() -> Config:
1. 不要推荐与用户播放列表中一模一样的歌曲。不要推荐用户不喜欢的歌曲。不要重复推荐。
2. 你返回的内容只应该有 JSON,其它信息都不需要。也不要用 markdown 格式返回。
3. 你推荐的歌曲需要使用类似这样的 JSON 格式
[{"title": "xxx", "artists_name": "yyy", "description": "推荐理由"}]
[{"title": "xxx", "artists": ["yyy", "zzz"], "description": "推荐理由"}]
''',
desc='AI 电台功能的提示词'
)
Expand Down
109 changes: 90 additions & 19 deletions feeluown/gui/drawers.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import math
import random
from typing import Optional

from PyQt5.QtCore import Qt, QRect, QPoint, QPointF
from PyQt5.QtCore import Qt, QRect, QPoint, QPointF, QRectF
from PyQt5.QtGui import (
QPainter, QBrush, QPixmap, QImage, QColor, QPolygonF, QPalette,
QPainterPath, QGuiApplication,
)
from PyQt5.QtWidgets import QWidget

from feeluown.gui.helpers import random_solarized_color, painter_save, IS_MACOS
from feeluown.gui.helpers import (
random_solarized_color, painter_save, IS_MACOS, SOLARIZED_COLORS,
)


class SizedPixmapDrawer:
Expand All @@ -18,6 +21,7 @@ class SizedPixmapDrawer:
Note that if device_pixel_ratio is not properly set, the drawed image
quality may be poor.
"""

def __init__(self, img: Optional[QImage], rect: QRect, radius: int = 0):
self._rect = rect
self._img_old_width = rect.width()
Expand Down Expand Up @@ -103,6 +107,7 @@ class PixmapDrawer(SizedPixmapDrawer):

TODO: rename this drawer to WidgetPixmapDrawer?
"""

def __init__(self, img, widget: QWidget, radius: int = 0):
"""
:param widget: a object which has width() and height() method.
Expand Down Expand Up @@ -147,16 +152,16 @@ def draw(self, painter: QPainter):
# Draw body.
x, y = self._padding, self._length // 2
width, height = self._length // 2, self._length // 2
painter.drawArc(x, y, width, height, 0, 60*16)
painter.drawArc(x, y, width, height, 120*16, 60*16)
painter.drawArc(x, y, width, height, 0, 60 * 16)
painter.drawArc(x, y, width, height, 120 * 16, 60 * 16)


class PlusIconDrawer:
def __init__(self, length, padding):
self.top = QPoint(length//2, padding)
self.bottom = QPoint(length//2, length - padding)
self.left = QPoint(padding, length//2)
self.right = QPoint(length-padding, length//2)
self.top = QPoint(length // 2, padding)
self.bottom = QPoint(length // 2, length - padding)
self.left = QPoint(padding, length // 2)
self.right = QPoint(length - padding, length // 2)

def draw(self, painter):
pen = painter.pen()
Expand Down Expand Up @@ -208,7 +213,7 @@ def set_direction(self, direction):
right = QPointF(real_padding + diameter, half)

d60 = diameter / 2 * 0.87 # sin60
d30 = diameter / 2 / 2 # sin30
d30 = diameter / 2 / 2 # sin30

if direction in ('left', 'right'):
left_x = half - d30
Expand Down Expand Up @@ -247,14 +252,81 @@ def draw(self, painter):
painter.drawPolygon(self.triangle)


class AIIconDrawer:
def __init__(self, length, padding, colorful=False):

sr = length / 12 # small circle radius
sd = sr * 2

half = length / 2
diameter = length - 2 * padding - sd
real_padding = (length - diameter) / 2
d60 = diameter / 2 * 0.87 # sin60
d30 = diameter / 2 / 2 # sin30
left_x = half - d60
bottom_y = half + d30
right_x = half + d60

self._center_rect = QRectF(real_padding, real_padding, diameter, diameter)
self._top_circle = QRectF(half - sr, padding, sd, sd)
self._left_circle = QRectF(left_x - sr, bottom_y - sr, sd, sd)
self._right_circle = QRectF(right_x - sr, bottom_y - sr, sd, sd)

self.colorful = colorful
self._colors = [QColor(e) for e in SOLARIZED_COLORS.values()]
self._colors_count = len(self._colors)

def draw(self, painter, palette):
if self.colorful:
self._draw_colorful(painter, palette)
else:
self._draw_bw(painter, palette)

def _draw_bw(self, painter, palette):
pen = painter.pen()
pen.setWidthF(1.5)
painter.setPen(pen)
with painter_save(painter):
painter.drawEllipse(self._center_rect)
painter.setBrush(palette.color(QPalette.Window))
painter.drawEllipse(self._top_circle)
painter.drawEllipse(self._left_circle)
painter.drawEllipse(self._right_circle)

def _draw_colorful(self, painter, palette):
pen = painter.pen()
pen.setWidthF(1.5)
pen.setColor(self._colors[random.randint(0, self._colors_count - 1)])
painter.setPen(pen)
with painter_save(painter):
start_alen = 120 * 16
pen.setColor(self._colors[5])
painter.setPen(pen)
painter.drawArc(self._center_rect, 0, start_alen)
pen.setColor(self._colors[1])
painter.setPen(pen)
painter.drawArc(self._center_rect, start_alen, start_alen)
pen.setColor(self._colors[4])
painter.setPen(pen)
painter.drawArc(self._center_rect, start_alen * 2, start_alen)

painter.setPen(Qt.NoPen)
painter.setBrush(self._colors[5])
painter.drawEllipse(self._top_circle)
painter.setBrush(self._colors[1])
painter.drawEllipse(self._left_circle)
painter.setBrush(self._colors[4])
painter.drawEllipse(self._right_circle)


class HomeIconDrawer:
def __init__(self, length, padding):
icon_length = length
diff = 1 # root/body width diff
h_padding = v_padding = padding

body_left_x = h_padding + diff*2
body_right_x = icon_length - h_padding - diff*2
body_left_x = h_padding + diff * 2
body_right_x = icon_length - h_padding - diff * 2
body_top_x = icon_length // 2

self._roof = QPoint(icon_length // 2, v_padding)
Expand Down Expand Up @@ -296,7 +368,7 @@ def paint(self, painter: QPainter):

class RankIconDrawer:
def __init__(self, length, padding):
body = length - 2*padding
body = length - 2 * padding
body_2 = body // 2
body_8 = body // 8
body_3 = body // 3
Expand Down Expand Up @@ -324,8 +396,7 @@ def paint(self, painter: QPainter):

class StarIconDrawer:
def __init__(self, length, padding):

radius_outer = (length - 2*padding)//2
radius_outer = (length - 2 * padding) // 2
length_half = length // 2
radius_inner = radius_outer // 2
center = QPointF(length_half, length_half)
Expand All @@ -339,8 +410,8 @@ def __init__(self, length, padding):
)
self._star_polygon.append(outer_point)
inner_point = center + QPointF(
radius_inner * math.cos(angle + math.pi/5),
-radius_inner * math.sin(angle + math.pi/5)
radius_inner * math.cos(angle + math.pi / 5),
-radius_inner * math.sin(angle + math.pi / 5)
)
self._star_polygon.append(inner_point)
angle += 2 * math.pi / 5
Expand Down Expand Up @@ -407,9 +478,9 @@ def draw(self, painter: QPainter, palette: QPalette):
disabled_lines = ()
elif self._volume >= 33:
lines = (self._line2, self._line3)
disabled_lines = (self._line1, )
disabled_lines = (self._line1,)
elif self._volume > 0:
lines = (self._line3, )
lines = (self._line3,)
disabled_lines = (self._line1, self._line2)
else:
lines = ()
Expand Down Expand Up @@ -493,6 +564,6 @@ def paint(self, painter: QPainter):
font.setPixelSize(width - 4)
else:
# -1 works well on KDE when length is in range(30, 200)
font.setPixelSize(width - (self._length//20))
font.setPixelSize(width - (self._length // 20))
painter.setFont(font)
painter.drawText(0, 0, width, width, Qt.AlignHCenter | Qt.AlignVCenter, self._emoji)
12 changes: 10 additions & 2 deletions feeluown/gui/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,18 @@ def __init__(self, app):
self._splitter = QSplitter(app)

# Create widgets that don't rely on other widgets first.
try:
from feeluown.gui.uimain.ai_chat import AIChatOverlay
except ImportError as e:
logger.warning(f'AIChatOverlay is not available: {e}')
self.ai_chat_overlay = None
else:
self.ai_chat_overlay = AIChatOverlay(app, parent=app)
self.ai_chat_overlay.hide()
self.lyric_window = LyricWindow(self._app)
self.lyric_window.hide()
self.playlist_overlay = PlaylistOverlay(app, parent=app)
self.nowplaying_overlay = NowplayingOverlay(app, parent=app)

# NOTE: 以位置命名的部件应该只用来组织界面布局,不要
# 给其添加任何功能性的函数
Expand All @@ -39,8 +49,6 @@ def __init__(self, app):
self.page_view = self.right_panel = RightPanel(self._app, self._splitter)
self.toolbar = self.bottom_panel = self.right_panel.bottom_panel
self.mpv_widget = MpvOpenGLWidget(self._app)
self.playlist_overlay = PlaylistOverlay(app, parent=app)
self.nowplaying_overlay = NowplayingOverlay(app, parent=app)

# alias
self.magicbox = self.bottom_panel.magicbox
Expand Down
Loading
Loading