Skip to content

Commit 8815a45

Browse files
committed
feat: Chat record share link
1 parent 90f8160 commit 8815a45

File tree

11 files changed

+412
-4
lines changed

11 files changed

+412
-4
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""
2+
@project: MaxKB
3+
@Author: niu
4+
@file: application_chat_link.py
5+
@date: 2026/2/9 16:59
6+
@desc:
7+
"""
8+
from drf_spectacular.types import OpenApiTypes
9+
from drf_spectacular.utils import OpenApiParameter
10+
from django.utils.translation import gettext_lazy as _
11+
12+
from application.serializers.application_chat_link import ChatRecordShareLinkRequestSerializer
13+
from common.mixins.api_mixin import APIMixin
14+
from common.result import DefaultResultSerializer
15+
16+
17+
class ChatRecordLinkAPI(APIMixin):
18+
@staticmethod
19+
def get_response():
20+
return DefaultResultSerializer
21+
22+
@staticmethod
23+
def get_request():
24+
return ChatRecordShareLinkRequestSerializer
25+
26+
@staticmethod
27+
def get_parameters():
28+
return [
29+
OpenApiParameter(
30+
name="workspace_id",
31+
description="工作空间id",
32+
type=OpenApiTypes.STR,
33+
location='path',
34+
required=True,
35+
),
36+
OpenApiParameter(
37+
name="application_id",
38+
description="Application ID",
39+
type=OpenApiTypes.STR,
40+
location='path',
41+
required=True,
42+
),
43+
OpenApiParameter(
44+
name="chat_id",
45+
description=_("Chat ID"),
46+
type=OpenApiTypes.STR,
47+
location='path',
48+
required=True,
49+
),
50+
]
51+
52+
class ChatRecordDetailShareAPI(APIMixin):
53+
@staticmethod
54+
def get_response():
55+
return DefaultResultSerializer
56+
57+
58+
59+
@staticmethod
60+
def get_parameters():
61+
return [
62+
OpenApiParameter(
63+
name="link",
64+
description="链接",
65+
type=OpenApiTypes.STR,
66+
location='path',
67+
required=True,
68+
)
69+
]
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Generated by Django 5.2.9 on 2026-02-09 02:39
2+
3+
import django.contrib.postgres.fields
4+
import django.db.models.deletion
5+
import uuid_utils.compat
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('application', '0009_clean_application_knowledge_mapping'),
13+
('users', '0001_initial'),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name='ChatShareLink',
19+
fields=[
20+
('create_time', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间')),
21+
('update_time', models.DateTimeField(auto_now=True, db_index=True, verbose_name='修改时间')),
22+
('id', models.UUIDField(default=uuid_utils.compat.uuid7, editable=False, primary_key=True, serialize=False, verbose_name='主键id')),
23+
('share_type', models.CharField(choices=[('PUBLIC', 'public'), ('PRIVATE', 'private')], default='PUBLIC', max_length=20)),
24+
('chat_record_ids', django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(), size=None)),
25+
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='application.application')),
26+
('chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='application.chat')),
27+
('user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.SET_NULL, to='users.user')),
28+
],
29+
options={
30+
'db_table': 'application_chat_share_link',
31+
},
32+
),
33+
]

apps/application/models/application_chat.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from application.models import Application
1616
from common.encoder.encoder import SystemEncoder
1717
from common.mixins.app_model_mixin import AppModelMixin
18+
from users.models import User
1819

1920

2021
class ChatUserType(models.TextChoices):
@@ -64,6 +65,9 @@ class VoteReasonChoices(models.TextChoices):
6465
INCOMPLETE = 'incomplete', '内容不完善'
6566
OTHER = 'other', '其他'
6667

68+
class ShareLinkType(models.TextChoices):
69+
PUBLIC = "PUBLIC", 'public'
70+
PRIVATE = "PRIVATE", 'private'
6771

6872
class ChatSourceChoices(models.TextChoices):
6973
ONLINE = "ONLINE", "线上使用"
@@ -138,3 +142,14 @@ class Meta:
138142
indexes = [
139143
models.Index(fields=['application_id', 'chat_user_id']),
140144
]
145+
146+
class ChatShareLink(AppModelMixin):
147+
id = models.UUIDField(primary_key=True, max_length=128, default=uuid.uuid7, editable=False, verbose_name="主键id")
148+
chat = models.ForeignKey(Chat, on_delete=models.CASCADE)
149+
application = models.ForeignKey(Application,on_delete=models.CASCADE)
150+
share_type = models.CharField(max_length=20, choices=ShareLinkType.choices, default=ShareLinkType.PUBLIC)
151+
user = models.ForeignKey(User, on_delete=models.SET_NULL, db_constraint=False, blank=True, null=True)
152+
chat_record_ids = ArrayField(base_field=models.UUIDField(max_length=128))
153+
154+
class Meta:
155+
db_table = "application_chat_share_link"
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""
2+
@project: MaxKB
3+
@Author: niu
4+
@file: application_chat_link.py
5+
@date: 2026/2/9 10:50
6+
@desc:
7+
"""
8+
from django.utils.translation import gettext_lazy as _
9+
from rest_framework import serializers
10+
11+
from application.models import Chat, ChatShareLink, ShareLinkType, ChatRecord
12+
from common.exception.app_exception import AppApiException
13+
from common.utils.chat_link_code import UUIDEncoder
14+
import uuid_utils.compat as uuid
15+
16+
17+
class ShareChatRecordModelSerializer(serializers.ModelSerializer):
18+
class Meta:
19+
model = ChatRecord
20+
fields = ['id', 'problem_text', 'answer_text', 'answer_text_list', 'create_time']
21+
22+
class ChatRecordShareLinkRequestSerializer(serializers.Serializer):
23+
chat_record_ids = serializers.ListSerializer(
24+
child=serializers.UUIDField(),
25+
required=False,
26+
allow_empty=False,
27+
label=_("Chat record IDs")
28+
)
29+
is_current_all = serializers.BooleanField(required=False, default=False)
30+
31+
def validate(self, attrs):
32+
if not attrs.get('is_current_all') and not attrs.get('chat_record_ids'):
33+
raise serializers.ValidationError(_('Chat record ids can not be empty'))
34+
return attrs
35+
36+
class ChatRecordShareLinkSerializer(serializers.Serializer):
37+
chat_id = serializers.UUIDField(required=True, label=_("Conversation ID"))
38+
workspace_id = serializers.CharField(required=False, allow_null=True, allow_blank=True, label=_("Workspace ID"))
39+
application_id = serializers.UUIDField(required=True, label=_("Application ID"))
40+
user_id = serializers.UUIDField(required=False, label=_("User ID"))
41+
42+
def is_valid(self, *, raise_exception=False):
43+
super().is_valid(raise_exception=True)
44+
chat_id = self.data.get('chat_id')
45+
application_id = self.data.get('application_id')
46+
47+
chat_query_set = Chat.objects.filter(id=chat_id, application_id=application_id, is_deleted=False)
48+
if not chat_query_set.exists():
49+
raise AppApiException(500, _('Chat id does not exist'))
50+
51+
def generate_link(self, instance, with_valid=True):
52+
if with_valid:
53+
request_serializer = ChatRecordShareLinkRequestSerializer(data=instance)
54+
request_serializer.is_valid(raise_exception=True)
55+
self.is_valid(raise_exception=True)
56+
if not instance.get('is_current_all', False):
57+
chat_record_ids: list[str] = instance.get('chat_record_ids')
58+
59+
record_count = ChatRecord.objects.filter(id__in=chat_record_ids, chat_id=self.data.get('chat_id')).count()
60+
if record_count != len(chat_record_ids):
61+
raise AppApiException(500, _('Invalid chat record ids'))
62+
chat_id = self.data.get('chat_id')
63+
application_id = self.data.get('application_id')
64+
user_id = self.data.get('user_id')
65+
66+
is_current_all = instance.get('is_current_all', False)
67+
if is_current_all:
68+
sorted_ids = list(
69+
ChatRecord.objects.filter(chat_id=chat_id).order_by('create_time').values_list('id',flat=True)
70+
)
71+
else:
72+
chat_record_ids: list[str] = instance.get('chat_record_ids')
73+
sorted_ids = list(ChatRecord.objects.filter(id__in=chat_record_ids).order_by('create_time').values_list('id',flat=True))
74+
75+
existing = ChatShareLink.objects.filter(
76+
chat_id=chat_id, application_id=application_id,
77+
share_type=ShareLinkType.PUBLIC,
78+
user_id=user_id,
79+
chat_record_ids=sorted_ids
80+
).first()
81+
82+
if existing:
83+
return {'link': UUIDEncoder.encode(existing.id)}
84+
85+
chat_share_link_model = ChatShareLink(
86+
id=uuid.uuid7(),
87+
chat_id=chat_id,
88+
application_id=application_id,
89+
share_type=ShareLinkType.PUBLIC,
90+
user_id=user_id,
91+
chat_record_ids=sorted_ids
92+
)
93+
chat_share_link_model.save()
94+
95+
link = UUIDEncoder.encode(chat_share_link_model.id)
96+
97+
return {'link': link}
98+
99+
100+
class ChatShareLinkDetailSerializer(serializers.Serializer):
101+
link = serializers.CharField(required=True, label=_("Link"))
102+
103+
def is_valid(self, *, raise_exception=False):
104+
super().is_valid(raise_exception=True)
105+
106+
link = self.data.get('link')
107+
share_link_id = UUIDEncoder.decode_to_str(link)
108+
109+
share_link_query_set = ChatShareLink.objects.filter(id=share_link_id).first()
110+
if not share_link_query_set:
111+
raise AppApiException(500, _('Share link does not exist'))
112+
if share_link_query_set.chat.is_deleted:
113+
raise AppApiException(500, _('Chat has been deleted'))
114+
115+
return share_link_query_set
116+
117+
def get_record_list(self):
118+
share_link_model = self.is_valid(raise_exception=True)
119+
120+
chat_record_model_list = ChatRecord.objects.filter(id__in=share_link_model.chat_record_ids,
121+
chat_id=share_link_model.chat_id).order_by('create_time')
122+
123+
abstract = Chat.objects.filter(
124+
id=share_link_model.chat_id
125+
).values_list('abstract', flat=True).first()
126+
chat_record_list = ShareChatRecordModelSerializer(chat_record_model_list, many=True).data
127+
128+
return {
129+
'abstract': abstract,
130+
'chat_record_list': chat_record_list
131+
}

apps/application/urls.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
path('workspace/<str:workspace_id>/application/<str:application_id>/chat', views.ApplicationChat.as_view()),
2525
path('workspace/<str:workspace_id>/application/<str:application_id>/chat/export', views.ApplicationChat.Export.as_view()),
2626
path('workspace/<str:workspace_id>/application/<str:application_id>/chat/<int:current_page>/<int:page_size>', views.ApplicationChat.Page.as_view()),
27+
path('workspace/<str:workspace_id>/application/<str:application_id>/chat/<str:chat_id>/share_chat', views.ChatRecordLinkView.as_view()),
2728
path('workspace/<str:workspace_id>/application/<str:application_id>/chat/<str:chat_id>/chat_record', views.ApplicationChatRecord.as_view()),
2829
path('workspace/<str:workspace_id>/application/<str:application_id>/chat/<str:chat_id>/chat_record/<str:chat_record_id>', views.ApplicationChatRecordOperateAPI.as_view()),
2930
path('workspace/<str:workspace_id>/application/<str:application_id>/chat/<str:chat_id>/chat_record/<int:current_page>/<int:page_size>', views.ApplicationChatRecord.Page.as_view()),
@@ -39,5 +40,5 @@
3940
path('workspace/<str:workspace_id>/application/<str:application_id>/mcp_tools', views.McpServers.as_view()),
4041
path('workspace/<str:workspace_id>/application/<str:application_id>/model/<str:model_id>/prompt_generate', views.PromptGenerateView.as_view()),
4142
path('chat_message/<str:chat_id>', views.ChatView.as_view()),
42-
43+
path('chat/share/<str:link>', views.ChatRecordDetailView.as_view()),
4344
]

apps/application/views/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@
1313
from .application_stats import *
1414
from .application_chat import *
1515
from .application_chat_record import *
16+
from .application_chat_link import *
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""
2+
@project: MaxKB
3+
@Author: niu
4+
@file: application_chat_link.py
5+
@date: 2026/2/9 10:44
6+
@desc:
7+
"""
8+
from django.utils.translation import gettext_lazy as _
9+
from drf_spectacular.utils import extend_schema
10+
from rest_framework.request import Request
11+
from rest_framework.views import APIView
12+
13+
from application.api.application_chat_link import ChatRecordLinkAPI, ChatRecordDetailShareAPI
14+
from application.serializers.application_chat_link import ChatRecordShareLinkSerializer, ChatShareLinkDetailSerializer
15+
from common import result
16+
from common.auth import ChatTokenAuth
17+
18+
19+
class ChatRecordLinkView(APIView):
20+
authentication_classes = [ChatTokenAuth]
21+
22+
@extend_schema(
23+
methods=['POST'],
24+
description=_("Generate share link"),
25+
summary=_("Generate share link"),
26+
operation_id=_("Generate share link"), # type: ignore
27+
request=ChatRecordLinkAPI.get_request(),
28+
parameters=ChatRecordLinkAPI.get_parameters(),
29+
responses=ChatRecordLinkAPI.get_response(),
30+
tags=[_("Chat record link")] # type: ignore
31+
)
32+
33+
def post(self, request: Request, workspace_id: str, application_id: str, chat_id: str):
34+
return result.success(ChatRecordShareLinkSerializer(data={
35+
"workspace_id": workspace_id,
36+
"application_id": application_id,
37+
"chat_id": chat_id,
38+
"user_id": request.auth.chat_user_id
39+
}).generate_link(request.data))
40+
41+
42+
class ChatRecordDetailView(APIView):
43+
44+
@extend_schema(
45+
methods=['GET'],
46+
description=_("Get chat record by share link"),
47+
summary=_("Get chat record by share link"),
48+
operation_id=_("Get chat record by share link"), # type: ignore
49+
parameters=ChatRecordDetailShareAPI.get_parameters(),
50+
responses=ChatRecordDetailShareAPI.get_response(),
51+
tags=[_("Chat record link")] # type: ignore
52+
)
53+
def get(self, request, link: str):
54+
return result.success(
55+
ChatShareLinkDetailSerializer(data={'link':link}).get_record_list()
56+
)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""
2+
@project: MaxKB
3+
@Author: niu
4+
@file: chat_link_code.py
5+
@date: 2026/2/9 11:31
6+
@desc:
7+
"""
8+
from typing import Union
9+
10+
import uuid_utils.compat as uuid
11+
12+
13+
class UUIDEncoder:
14+
15+
BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
16+
BASE62_LEN = 62
17+
18+
@staticmethod
19+
def encode(uuid_obj: Union[uuid.UUID, str] = None) -> str:
20+
21+
if uuid_obj is None:
22+
uuid_obj = uuid.uuid7()
23+
elif isinstance(uuid_obj, str):
24+
uuid_obj = uuid.UUID(uuid_obj)
25+
26+
num = int(uuid_obj.hex, 16)
27+
28+
if num == 0:
29+
return UUIDEncoder.BASE62_ALPHABET[0]
30+
31+
result = []
32+
while num:
33+
num, rem = divmod(num,62)
34+
result.append(UUIDEncoder.BASE62_ALPHABET[rem])
35+
return ''.join(reversed(result))
36+
37+
@staticmethod
38+
def decode(encoded: str) -> uuid.UUID:
39+
40+
num = 0
41+
for char in encoded:
42+
num = num * UUIDEncoder.BASE62_LEN + UUIDEncoder.BASE62_ALPHABET.index(char)
43+
44+
return uuid.UUID(int=num)
45+
46+
@staticmethod
47+
def decode_to_str(encoded: str) -> str:
48+
return str(UUIDEncoder.decode(encoded))

0 commit comments

Comments
 (0)