Skip to content

Commit

Permalink
[feat](AI) add several AI related features (#901)
Browse files Browse the repository at this point in the history
- [x] user can input `=== {question}` to chat with AI
- [x] find standby using AI if standby is not found
  - [x] add a config
- [x] user can input `==> {title} - {artists_name}` in search bar to play a song
- [x] user can create a playlist based on plain text
  • Loading branch information
cosven authored Feb 2, 2025
1 parent cd4404c commit d997916
Show file tree
Hide file tree
Showing 22 changed files with 943 additions and 163 deletions.
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

0 comments on commit d997916

Please sign in to comment.