Skip to content
Open
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
86 changes: 62 additions & 24 deletions Plugins/SecondDimensionWatcherReDive.Chat/ChatController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using SecondDimensionWatcherReDive.AI.Models;
using SecondDimensionWatcherReDive.Chat.External;
using SecondDimensionWatcherReDive.Chat.Tools;
using SecondDimensionWatcherReDive.Framework.Authorization;
using SecondDimensionWatcherReDive.Framework.DataRepository;

namespace SecondDimensionWatcherReDive.Chat;
Expand Down Expand Up @@ -66,16 +67,19 @@ public async Task<IActionResult> GetModels(CancellationToken cancellationToken)
}

[HttpGet("conversations")]
public async Task<IReadOnlyList<ChatConversationSummary>> GetConversations(
public async Task<IActionResult> GetConversations(
CancellationToken cancellationToken)
{
return await chatRepository.GetConversationsAsync(cancellationToken);
if (!User.TryGetProfileId(out var profileId)) return Unauthorized();
return Ok(await chatRepository.GetConversationsAsync(profileId, cancellationToken));
}

[HttpGet("conversations/{id:guid}")]
public async Task<IActionResult> GetConversation(Guid id, CancellationToken cancellationToken)
{
var detail = await chatRepository.GetConversationWithMessagesAsync(id, cancellationToken);
if (!User.TryGetProfileId(out var profileId)) return Unauthorized();
var detail = await chatRepository.GetConversationWithMessagesAsync(
id, profileId, cancellationToken);
if (detail is null)
{
LogConversationNotFound(id);
Expand All @@ -85,19 +89,25 @@ public async Task<IActionResult> GetConversation(Guid id, CancellationToken canc
}

[HttpPost("conversations")]
public async Task<ChatConversationSummary> CreateConversation(
[Authorize(Policy = AccessPolicies.ChatWrite)]
public async Task<IActionResult> CreateConversation(
[FromBody] CreateConversationRequest? request,
CancellationToken cancellationToken)
{
var conv = await chatRepository.CreateConversationAsync(request?.Title, cancellationToken);
if (!User.TryGetProfileId(out var profileId)) return Unauthorized();
var conv = await chatRepository.CreateConversationAsync(
profileId, request?.Title, cancellationToken);
LogConversationCreated(conv.Id, request?.Title);
return conv;
return Ok(conv);
}

[HttpDelete("conversations/{id:guid}")]
[Authorize(Policy = AccessPolicies.ChatWrite)]
public async Task<IActionResult> DeleteConversation(Guid id, CancellationToken cancellationToken)
{
var deleted = await chatRepository.DeleteConversationAsync(id, cancellationToken);
if (!User.TryGetProfileId(out var profileId)) return Unauthorized();
var deleted = await chatRepository.DeleteConversationAsync(
id, profileId, cancellationToken);
if (deleted)
LogConversationDeleted(id);
else
Expand All @@ -106,41 +116,50 @@ public async Task<IActionResult> DeleteConversation(Guid id, CancellationToken c
}

[HttpPatch("conversations/{id:guid}")]
[Authorize(Policy = AccessPolicies.ChatWrite)]
public async Task<IActionResult> UpdateConversationTitle(
Guid id,
[FromBody] UpdateConversationRequest request,
CancellationToken cancellationToken)
{
await chatRepository.UpdateConversationTitleAsync(id, request.Title, cancellationToken);
if (!User.TryGetProfileId(out var profileId)) return Unauthorized();
await chatRepository.UpdateConversationTitleAsync(
id, profileId, request.Title, cancellationToken);
return Ok();
}

[HttpPost("conversations/{id:guid}/messages")]
[Authorize(Policy = AccessPolicies.ChatWrite)]
public async Task<IResult> SendMessage(
Guid id,
[FromBody] SendMessageRequest request,
CancellationToken cancellationToken)
{
if (!User.TryGetProfileId(out var profileId))
return TypedResults.Unauthorized();
var aiEngine = serviceProvider.GetService<IAIEngine>();
var status = serviceProvider.GetService<IAIEngineStatus>();
if (aiEngine is null || status is { IsConfigured: false })
return TypedResults.StatusCode(503);

var conversation = await chatRepository.GetConversationWithMessagesAsync(id, cancellationToken);
var conversation = await chatRepository.GetConversationWithMessagesAsync(
id, profileId, cancellationToken);
if (conversation is null)
{
LogConversationNotFound(id);
return TypedResults.NotFound();
}

// Get current message count for ordering
var messageOrder = await chatRepository.GetMessageCountAsync(id, cancellationToken);
var messageOrder = await chatRepository.GetMessageCountAsync(
id, profileId, cancellationToken);

// Save user message
var userMessage = new ChatMessageRecord(
Guid.NewGuid(), "user", request.Content, null, null, null,
messageOrder, DateTimeOffset.Now);
await chatRepository.AddMessageAsync(id, userMessage, cancellationToken);
await chatRepository.AddMessageAsync(
id, profileId, userMessage, cancellationToken);
messageOrder++;

LogUserMessageReceived(id, messageOrder - 1, request.Content.Length);
Expand All @@ -154,15 +173,21 @@ public async Task<IResult> SendMessage(
var messages = BuildMessagesFromHistory(conversation.Messages, request.Content);
LogHistoryBuilt(id, messages.Count);

var toolExecutor = new ToolExecutorBuilder(serviceProvider)
IToolExecutorBuilder toolBuilder = new ToolExecutorBuilder(serviceProvider)
.AddTool<QueryAnimationsTool>()
.AddTool<ManageFeedsTool>()
.AddTool<QuerySeasonTool>()
.AddTool<SubscribeBangumiTool>()
.AddTool<ManageTasksTool>()
.AddTool<ManageDownloadsTool>()
.AddTool<QueryFilesTool>()
.Build();
.AddTool<QueryFilesTool>();
if (User.IsInRole(nameof(UserRole.Admin))
|| User.IsInRole(nameof(UserRole.Member)))
{
toolBuilder = toolBuilder
.AddTool<ManageFeedsTool>()
.AddTool<SubscribeBangumiTool>()
.AddTool<ManageDownloadsTool>();
}
if (User.IsInRole(nameof(UserRole.Admin)))
toolBuilder = toolBuilder.AddTool<ManageTasksTool>();
var toolExecutor = toolBuilder.Build();

var chatOptions = new ChatOptions
{
Expand All @@ -174,7 +199,7 @@ public async Task<IResult> SendMessage(
LogStreamingStarted(id, request.Model);

return TypedResults.ServerSentEvents(
StreamChatEvents(aiEngine, messages, chatOptions, id, messageOrder,
StreamChatEvents(aiEngine, messages, chatOptions, id, profileId, messageOrder,
request.Content, !hadPriorAssistant && titleEligible, request.Model,
cancellationToken));
}
Expand All @@ -184,6 +209,7 @@ private async IAsyncEnumerable<SseItem<string>> StreamChatEvents(
List<IMessage> messages,
ChatOptions chatOptions,
Guid conversationId,
Guid profileId,
int messageOrder,
string firstUserMessage,
bool autoTitleEligible,
Expand All @@ -196,7 +222,7 @@ private async IAsyncEnumerable<SseItem<string>> StreamChatEvents(
// Keep the task and await it during iterator disposal so a disconnected request cannot
// release this controller's scoped repository before tool-call audit records are saved.
var producer = ProduceChatEventsAsync(
aiEngine, messages, chatOptions, conversationId, messageOrder,
aiEngine, messages, chatOptions, conversationId, profileId, messageOrder,
firstUserMessage, autoTitleEligible, model,
channel.Writer, cancellationToken);

Expand All @@ -219,6 +245,7 @@ private async Task ProduceChatEventsAsync(
List<IMessage> messages,
ChatOptions chatOptions,
Guid conversationId,
Guid profileId,
int messageOrder,
string firstUserMessage,
bool autoTitleEligible,
Expand Down Expand Up @@ -344,7 +371,8 @@ await writer.WriteAsync(

if (messagesToSave.Count > 0)
{
await chatRepository.AddMessagesAsync(conversationId, messagesToSave, CancellationToken.None);
await chatRepository.AddMessagesAsync(
conversationId, profileId, messagesToSave, CancellationToken.None);
LogMessagesSaved(conversationId, messagesToSave.Count);

// Capture data needed for the post-stream auto-title task.
Expand Down Expand Up @@ -372,7 +400,8 @@ await writer.WriteAsync(
// stalled provider can never hang the conversation.
if (firstAssistantContentForTitle is not null)
{
_ = RunAutoTitleAsync(conversationId, firstUserMessage, firstAssistantContentForTitle, model);
_ = RunAutoTitleAsync(
conversationId, profileId, firstUserMessage, firstAssistantContentForTitle, model);
}
}

Expand All @@ -394,15 +423,24 @@ private static async Task WriteToolAuditEventAsync(
}

private async Task RunAutoTitleAsync(
Guid conversationId, string firstUserMessage, string firstAssistantMessage, string? model)
Guid conversationId,
Guid profileId,
string firstUserMessage,
string firstAssistantMessage,
string? model)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var generator = scope.ServiceProvider.GetRequiredService<IConversationTitleGenerator>();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await generator.TryAutoTitleAsync(
conversationId, firstUserMessage, firstAssistantMessage, model, cts.Token);
conversationId,
profileId,
firstUserMessage,
firstAssistantMessage,
model,
cts.Token);
}
catch (Exception ex)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public static class ChatServiceExtensions
{
public static IServiceCollection AddChat(this IServiceCollection services)
{
services.AddHttpContextAccessor();
services.AddScoped<QueryAnimationsTool>();
services.AddScoped<ManageFeedsTool>();
services.AddScoped<QuerySeasonTool>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ internal interface IConversationTitleGenerator

Task TryAutoTitleAsync(
Guid conversationId,
Guid profileId,
string userMessage,
string assistantMessage,
string? model,
Expand Down Expand Up @@ -93,6 +94,7 @@ internal sealed partial class ConversationTitleGenerator(

public async Task TryAutoTitleAsync(
Guid conversationId,
Guid profileId,
string userMessage,
string assistantMessage,
string? model,
Expand All @@ -108,7 +110,8 @@ public async Task TryAutoTitleAsync(
}

// Race-safety: only persist if title is still unset on the latest snapshot.
var current = await chatRepository.GetConversationWithMessagesAsync(conversationId, cancellationToken);
var current = await chatRepository.GetConversationWithMessagesAsync(
conversationId, profileId, cancellationToken);
if (current is null)
return;
if (!IsAutoTitleEligible(current.Title))
Expand All @@ -117,7 +120,8 @@ public async Task TryAutoTitleAsync(
return;
}

await chatRepository.UpdateConversationTitleAsync(conversationId, title, cancellationToken);
await chatRepository.UpdateConversationTitleAsync(
conversationId, profileId, title, cancellationToken);
LogTitleSaved(conversationId, title);
}
catch (OperationCanceledException)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using SecondDimensionWatcherReDive.AI.Models;
using SecondDimensionWatcherReDive.Framework.AI;
using SecondDimensionWatcherReDive.Framework.Attributes;
using SecondDimensionWatcherReDive.Framework.Authorization;
using SecondDimensionWatcherReDive.Framework.DataRepository;
using SecondDimensionWatcherReDive.Framework.FileDownload;

Expand All @@ -12,7 +15,9 @@ namespace SecondDimensionWatcherReDive.Chat.Tools;
internal sealed partial class ManageDownloadsTool(
IAnimationInfoRepository animationInfoRepository,
IFileMappingRepository fileMappingRepository,
IFileDownloadClientProvider fileDownloadClientProvider) : ITool
IFileDownloadClientProvider fileDownloadClientProvider,
IHttpContextAccessor httpContextAccessor,
IAuthorizationService authorizationService) : ITool
{
private async Task<IToolResult> ExecuteCoreAsync(
ManageDownloadsParams param, CancellationToken cancellationToken)
Expand Down Expand Up @@ -111,6 +116,14 @@ private async Task<IToolResult> ResumeDownloadAsync(
private async Task<IToolResult> CancelDownloadAsync(
AnimationInfo info, IFileDownloadClient client, bool removeFile, CancellationToken cancellationToken)
{
if (removeFile)
{
var principal = httpContextAccessor.HttpContext?.User;
if (principal is null || !(await authorizationService.AuthorizeAsync(
principal, resource: null, AccessPolicies.RecentAdministrator)).Succeeded)
return new ToolFailureResult("Deleting downloaded files requires recent administrator authentication");
}

var cancellationAttemptId = info.DownloadCancellationId ?? Guid.NewGuid();
cancellationToken.ThrowIfCancellationRequested();
using (var beginCancellation = CreateDownloadSagaTokenSource())
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
- [x] HTTP 文件浏览和流媒体播放,支持外部播放器(VLC / PotPlayer / IINA / mpv / nPlayer)URL Scheme
- [x] WebDAV 只读网关(RFC 4918,按设备签发的 Basic 访问令牌,独立于 JWT)
- [x] JWT 认证 + 刷新令牌
- [x] 家庭账户与独立档案(Admin / Member / Viewer、PIN、会话撤销、独立播放/聊天状态)
- [x] AI 元数据推断(OpenAI / Anthropic)— 自动识别 TMDB ID、季度、集数、字幕组
- [x] 本地 Agent 执行模式(Codex app-server)— 同时用于元数据推断与对话助手
- [x] AI 对话助手:流式响应 + 7 个内置工具(动画 / 订阅 / 季度 / 下载 / 任务 / 文件查询)
Expand Down Expand Up @@ -90,7 +91,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/HCGStudio/SecondDimensionWat
|--------|------|
| `ConnectionStrings:sdw` | PostgreSQL 连接字符串 |
| `JwtSecret` | JWT 签名密钥 |
| `Password:Value` | 登录密码的 BCrypt 哈希;为空时允许首次注册写入 `password.json` |
| `Password:Value` | 旧版单站点密码的 BCrypt 哈希;存在时禁止公开注册,仅允许 `admin` 首次登录并迁移到数据库账户 |
| `DataProtection:KeyRingPath` | 网页保存的 API key/密码所用加密密钥环;必须位于持久化目录 |
| `Torrent:Remote:Url` | qBittorrent API 地址 |
| `FileStore:Local` | 下载文件存储根目录 |
Expand Down Expand Up @@ -118,6 +119,16 @@ bash <(curl -fsSL https://raw.githubusercontent.com/HCGStudio/SecondDimensionWat

设置页提交的 API key 和密码会经过浏览器与服务端之间的连接;除严格的本机访问外,必须为网页入口配置 HTTPS。配置带凭据的 AI 或 qBittorrent 端点时也应使用 TLS,或将明文 HTTP 严格限制在受信任的隔离网络内。

### 家庭账户、档案与设备访问

首次安装由注册页创建管理员和默认档案;旧实例若仍配置 `Password:Value`,注册入口会保持关闭,使用用户名 `admin` 和原密码首次登录后才会安全迁移。右上角档案菜单可即时切换档案,「账户与档案」页可管理名称、头像、可选 PIN、家庭用户和登录会话。档案切换会轮换访问/刷新令牌,并清除浏览器中上一档案的播放、聊天等缓存;多个标签页通过 Web Locks 与浏览器消息同步轮换结果。

角色权限由服务端强制执行:Admin 可管理全局设置、用户、任务、元数据和设备凭据;Member 可管理订阅、下载任务和播放状态,但删除已下载文件仍需近期管理员验证;Viewer 仅可浏览和播放。敏感管理操作在超过近期验证窗口后会要求再次输入账户密码,无需退出登录。

管理员可在「设置 → 访问协议」为指定家庭用户签发 WebDAV/VFS 设备凭据。每个凭据固定为只读,可限制虚拟根路径并设置到期时间;撤销、到期和路径边界同时由 WebDAV 与 VFS 强制执行。路径 `/Anime` 不会授权 `/Anime2`,客户端看到的根目录和 WebDAV href 会重写到所授权的命名空间。

升级迁移会把旧播放进度、偏好和聊天记录归入默认 `Home` 档案。旧数据库结构无法表达多用户、档案归属、token 根路径、到期或撤销状态;因此一旦创建了新身份数据或受限设备凭据,向该迁移之前降级会在删除任何列之前明确失败并保持数据库原样,避免静默合并历史或扩大已撤销凭据权限。

### 使用本地 Codex app-server

需要 Codex app-server 0.144.5 或兼容版本提供实验性的 `permissionProfile/list` 与 `permissions` 协议。应用默认请求 `:read-only` 权限配置,并在每次创建 thread 后核验服务端实际返回 `readOnly` 且 agent network access 为 `false`;服务端不支持该协议、配置不可用或结果更宽松时会拒绝执行。当前 `:read-only` **不会把主机文件读取范围收窄到空目录**,而 agent sandbox 的网络开关也不限制 app-server 自身访问模型 API,所以仍必须把进程当作能够读取其操作系统账号可读文件的服务来隔离。
Expand Down
3 changes: 2 additions & 1 deletion SecondDimensionWatcherReDive.Client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@parcel/core": "^2.16.4",
"@parcel/transformer-inline-string": "2.16.4",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^26.4.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@yarnpkg/sdks": "^3.3.1",
Expand All @@ -52,7 +53,7 @@
"build": "rimraf dist && parcel build --no-source-maps",
"mock": "node mock-server.mjs",
"dev": "node mock-server.mjs & parcel --no-cache",
"test": "tsx --test src/playback/mkv/*.test.ts"
"test": "tsx --test src/auth/*.test.ts src/playback/mkv/*.test.ts"
},
"source": "src/index.html",
"@parcel/resolver-default": {
Expand Down
12 changes: 12 additions & 0 deletions SecondDimensionWatcherReDive.Client/src/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { useTranslation } from "react-i18next";
import { createBrowserRouter } from "react-router";
import { RouterProvider } from "react-router/dom";

import { useAuthSynchronization } from "./auth/hooks";
import { ProtectedRoute } from "./components/ProtectedRoute";
import { AccountPage } from "./pages/AccountPage";
import { ChatPage } from "./pages/ChatPage";
import { DownloadedPage } from "./pages/DownloadedPage";
import { DownloadingPage } from "./pages/DownloadingPage";
Expand Down Expand Up @@ -136,6 +138,15 @@ const router = createBrowserRouter([
),
errorElement: <ErrorPage />,
},
{
path: "/account",
element: (
<ProtectedRoute>
<AccountPage />
</ProtectedRoute>
),
errorElement: <ErrorPage />,
},
{
path: "/settings",
element: (
Expand All @@ -153,6 +164,7 @@ const router = createBrowserRouter([
]);

export const Main: React.FC = () => {
useAuthSynchronization();
const { t } = useTranslation();
React.useEffect(() => {
document.title = `${t("appName")} Re:Dive`;
Expand Down
Loading