Compare commits
14 Commits
a5e8ba2b7e
...
e051a49f7a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e051a49f7a | ||
|
|
5fb75b50b4 | ||
|
|
88ab2d011a | ||
|
|
18dc7c6120 | ||
|
|
1e476a2097 | ||
|
|
9e4b64ba9e | ||
|
|
8273340f7f | ||
|
|
3307f79162 | ||
|
|
de23a6e921 | ||
|
|
aa75506364 | ||
|
|
c3900a11ec | ||
|
|
4262c8fb5c | ||
|
|
e4ff24a18f | ||
|
|
3cb4405b14 |
@@ -33,7 +33,7 @@
|
||||
- `src/boss_control`:空占位目录,不参与当前运行
|
||||
- `src/boss_device_agent`:空占位目录,不参与当前运行
|
||||
|
||||
## 当前运行状态(2026-03-28)
|
||||
## 当前运行状态(2026-03-29)
|
||||
|
||||
本地:
|
||||
|
||||
@@ -90,7 +90,7 @@ Android APK:
|
||||
- 已生成 Android debug APK:`android/app/build/outputs/apk/debug/app-debug.apk`
|
||||
- 已生成 Android signed release APK:`android/app/build/outputs/apk/release/app-release.apk`
|
||||
- `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
|
||||
- 当前最新 release 构建版本:`2.4.0`(`versionCode=12`)
|
||||
- 当前最新 release 构建版本:`2.5.0`(`versionCode=13`)
|
||||
- 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局`
|
||||
- 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于
|
||||
- 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab,会话首页是简单聊天列表,`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口
|
||||
@@ -107,6 +107,7 @@ Android APK:
|
||||
- `2.2.1` 已继续补齐原生交互细节:聊天页发送后会先出现本地“发送中”气泡,关于页会展示 OTA 下载进度 / 重试 / 安装授权提示,根 tab 会记住用户上次停留位置并改成“再按一次返回进入后台”
|
||||
- `2.3.0` 已把会话模型切到“线程 = 聊天窗口”,补上文件夹名副信息、后台活跃数量动态图标、微信式会话信息页、线程改名、独立群聊创建、群资料页,以及 `主 Agent / 审计对话` 普通置顶会话化
|
||||
- `2.4.0` 已把消息转发切到微信式原生链路:聊天页支持长按消息操作、多选合并转发、统一目标会话选择页;单条消息转发显示为普通转发消息,多条消息转发显示为“聊天记录”卡片
|
||||
- `2.5.0` 已补齐聊天附件主链:原生聊天框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;默认走服务器文件存储,`我的 > 附件与存储` 可切到阿里 OSS 私有桶;附件消息已支持下载 / 打开、手动分析、自动分析状态,以及带 task token 的主 Agent 附件分析链接
|
||||
|
||||
## 本地启动
|
||||
|
||||
@@ -287,6 +288,8 @@ npm run aab:release
|
||||
- `GET /api/v1/app-logs` 现在已支持登录态下按 `deviceId / projectId / level / category / source / cursor` 查询日志分页
|
||||
- 设备写接口 `POST /api/v1/app-logs`、`POST /api/v1/devices/[deviceId]/skills`、`POST /api/v1/workers/[workerId]/thread-context` 现在都要求有效设备 token 或匹配登录会话
|
||||
- 当前认证仍是 MVP:已有最小会话 Cookie,但还没有刷新令牌、跨端会话治理、吊销审计和 CSRF 防护
|
||||
- 当前图片 / 视频入口会写入消息账本,但真实文件上传还没有接对象存储
|
||||
- 聊天附件当前已支持真实上传、消息落账本、受保护下载和原生打开;默认存储后端为服务器文件存储
|
||||
- 当前用户已可在 `我的 > 附件与存储` 切到阿里 OSS 私有桶,下载链会按附件快照生成签名地址,避免用户后续修改配置后旧附件失效
|
||||
- 图片 / PDF / 文本默认自动进入主 Agent 附件分析;视频 / Office / 大文件默认手动触发
|
||||
- 当前采用“极轻云 + 本地设备端”的路线,云端只承载 Web、轻 API 和状态文件
|
||||
- 服务器侧主 Agent 对话能否返回真实大模型回复,依赖被绑定设备的 `local-agent` 在线并能执行 `codex exec`;服务器本身不直接持有主 GPT 会话
|
||||
|
||||
@@ -36,8 +36,8 @@ android {
|
||||
applicationId "com.hyzq.boss"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 12
|
||||
versionName "2.4.0"
|
||||
versionCode 13
|
||||
versionName "2.5.0"
|
||||
buildConfigField "String", "BOSS_API_BASE_URL", "\"https://boss.hyzq.net\""
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public final class AttachmentComposerState {
|
||||
private AttachmentComposerState() {}
|
||||
|
||||
public static boolean requiresConfirmation(@Nullable String sourceType) {
|
||||
return ProjectChatUiState.requiresAttachmentConfirmation(sourceType);
|
||||
}
|
||||
|
||||
public static final class PendingAttachment {
|
||||
public final String sourceType;
|
||||
public final String fileName;
|
||||
public final String mimeType;
|
||||
public final long fileSizeBytes;
|
||||
@Nullable public final Uri uri;
|
||||
|
||||
public PendingAttachment(
|
||||
String sourceType,
|
||||
String fileName,
|
||||
String mimeType,
|
||||
long fileSizeBytes,
|
||||
@Nullable Uri uri
|
||||
) {
|
||||
this.sourceType = sourceType == null ? "file" : sourceType;
|
||||
this.fileName = fileName == null || fileName.trim().isEmpty() ? "attachment" : fileName;
|
||||
this.mimeType = mimeType == null || mimeType.trim().isEmpty()
|
||||
? "application/octet-stream"
|
||||
: mimeType;
|
||||
this.fileSizeBytes = Math.max(fileSizeBytes, 0L);
|
||||
this.uri = uri;
|
||||
}
|
||||
|
||||
public boolean requiresConfirmation() {
|
||||
return AttachmentComposerState.requiresConfirmation(sourceType);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
@@ -19,6 +21,7 @@ import java.io.OutputStreamWriter;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -100,6 +103,79 @@ public class BossApiClient {
|
||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/messages", payload);
|
||||
}
|
||||
|
||||
public ApiResponse uploadAttachment(
|
||||
String projectId,
|
||||
String fileName,
|
||||
String mimeType,
|
||||
byte[] bytes,
|
||||
String sourceType
|
||||
) throws IOException, JSONException {
|
||||
return uploadAttachment(
|
||||
projectId,
|
||||
fileName,
|
||||
mimeType,
|
||||
new ByteArrayInputStream(bytes == null ? new byte[0] : bytes),
|
||||
sourceType
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse uploadAttachment(
|
||||
String projectId,
|
||||
String fileName,
|
||||
String mimeType,
|
||||
InputStream inputStream,
|
||||
String sourceType
|
||||
) throws IOException, JSONException {
|
||||
HttpURLConnection connection = openConnection("/api/v1/projects/" + encode(projectId) + "/attachments");
|
||||
prepareConnection(connection, "POST");
|
||||
connection.setDoOutput(true);
|
||||
|
||||
String boundary = "BossBoundary" + System.currentTimeMillis();
|
||||
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
|
||||
|
||||
try (OutputStream outputStream = connection.getOutputStream()) {
|
||||
writeMultipartPart(outputStream, boundary, "sourceType", sourceType, null);
|
||||
writeMultipartPart(
|
||||
outputStream,
|
||||
boundary,
|
||||
"file",
|
||||
inputStream,
|
||||
fileName,
|
||||
mimeType
|
||||
);
|
||||
outputStream.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
return executeConnection(connection, true);
|
||||
}
|
||||
|
||||
public ApiResponse analyzeAttachment(String projectId, String attachmentId) throws IOException, JSONException {
|
||||
return requestWithRestoreRaw(
|
||||
"POST",
|
||||
"/api/v1/projects/" + encode(projectId) + "/attachments/" + encode(attachmentId) + "/analyze",
|
||||
"{}"
|
||||
);
|
||||
}
|
||||
|
||||
public DownloadedAttachment downloadAttachment(
|
||||
String attachmentId,
|
||||
String fallbackFileName,
|
||||
String fallbackMimeType
|
||||
) throws IOException {
|
||||
DownloadedAttachment attachment = downloadAttachmentRaw(attachmentId, fallbackFileName, fallbackMimeType, true);
|
||||
if (attachment.statusCode == 401 && !getRestoreToken().isEmpty()) {
|
||||
try {
|
||||
ApiResponse restored = restoreSession();
|
||||
if (restored.ok()) {
|
||||
return downloadAttachmentRaw(attachmentId, fallbackFileName, fallbackMimeType, true);
|
||||
}
|
||||
} catch (JSONException exception) {
|
||||
throw new IOException("SESSION_RESTORE_FAILED", exception);
|
||||
}
|
||||
}
|
||||
return attachment;
|
||||
}
|
||||
|
||||
public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, JSONObject payload) throws IOException, JSONException {
|
||||
String requestBody = ForwardPayloads.toRequestBody(targetProjectId, payload);
|
||||
return requestWithRestoreRaw("POST", "/api/v1/projects/" + encode(projectId) + "/forwards", requestBody);
|
||||
@@ -269,6 +345,25 @@ public class BossApiClient {
|
||||
|
||||
private ApiResponse requestRaw(String method, String path, @Nullable String body, boolean expectProtected) throws IOException, JSONException {
|
||||
HttpURLConnection connection = openConnection(path);
|
||||
prepareConnection(connection, method);
|
||||
|
||||
if (body != null) {
|
||||
connection.setDoOutput(true);
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
try (OutputStream outputStream = connection.getOutputStream();
|
||||
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) {
|
||||
writer.write(body);
|
||||
}
|
||||
}
|
||||
|
||||
return executeConnection(connection, expectProtected);
|
||||
}
|
||||
|
||||
HttpURLConnection openConnection(String path) throws IOException {
|
||||
return (HttpURLConnection) new URL(baseUrl + path).openConnection();
|
||||
}
|
||||
|
||||
private void prepareConnection(HttpURLConnection connection, String method) throws IOException {
|
||||
connection.setRequestMethod(method);
|
||||
connection.setConnectTimeout(12000);
|
||||
connection.setReadTimeout(12000);
|
||||
@@ -281,16 +376,9 @@ public class BossApiClient {
|
||||
if (!cookie.isEmpty()) {
|
||||
connection.setRequestProperty("Cookie", cookie);
|
||||
}
|
||||
}
|
||||
|
||||
if (body != null) {
|
||||
connection.setDoOutput(true);
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
try (OutputStream outputStream = connection.getOutputStream();
|
||||
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) {
|
||||
writer.write(body);
|
||||
}
|
||||
}
|
||||
|
||||
private ApiResponse executeConnection(HttpURLConnection connection, boolean expectProtected) throws IOException, JSONException {
|
||||
int statusCode = connection.getResponseCode();
|
||||
captureSessionCookie(connection.getHeaderFields());
|
||||
JSONObject json = readJson(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
|
||||
@@ -305,8 +393,104 @@ public class BossApiClient {
|
||||
return new ApiResponse(statusCode, json == null ? new JSONObject() : json);
|
||||
}
|
||||
|
||||
HttpURLConnection openConnection(String path) throws IOException {
|
||||
return (HttpURLConnection) new URL(baseUrl + path).openConnection();
|
||||
private void writeMultipartPart(
|
||||
OutputStream outputStream,
|
||||
String boundary,
|
||||
String fieldName,
|
||||
String value,
|
||||
@Nullable String contentType
|
||||
) throws IOException {
|
||||
outputStream.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
outputStream.write(
|
||||
("Content-Disposition: form-data; name=\"" + fieldName + "\"\r\n")
|
||||
.getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
if (contentType != null && !contentType.isEmpty()) {
|
||||
outputStream.write(("Content-Type: " + contentType + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
outputStream.write("\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
outputStream.write((value == null ? "" : value).getBytes(StandardCharsets.UTF_8));
|
||||
outputStream.write("\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private void writeMultipartPart(
|
||||
OutputStream outputStream,
|
||||
String boundary,
|
||||
String fieldName,
|
||||
InputStream inputStream,
|
||||
String fileName,
|
||||
String contentType
|
||||
) throws IOException {
|
||||
outputStream.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
outputStream.write(
|
||||
String.format(
|
||||
Locale.US,
|
||||
"Content-Disposition: form-data; name=\"%s\"; filename=\"%s\"\r\n",
|
||||
fieldName,
|
||||
escapeMultipartValue(fileName)
|
||||
).getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
outputStream.write(
|
||||
("Content-Type: " + (contentType == null || contentType.isEmpty()
|
||||
? "application/octet-stream"
|
||||
: contentType) + "\r\n\r\n").getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
if (inputStream != null) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
outputStream.write("\r\n".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private DownloadedAttachment downloadAttachmentRaw(
|
||||
String attachmentId,
|
||||
String fallbackFileName,
|
||||
String fallbackMimeType,
|
||||
boolean expectProtected
|
||||
) throws IOException {
|
||||
HttpURLConnection connection = openConnection("/api/v1/attachments/" + encode(attachmentId) + "/download");
|
||||
prepareConnection(connection, "GET");
|
||||
int statusCode = connection.getResponseCode();
|
||||
captureSessionCookie(connection.getHeaderFields());
|
||||
|
||||
if (statusCode >= 400) {
|
||||
String errorBody = readText(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
|
||||
if (statusCode == 401 && !expectProtected) {
|
||||
clearSession();
|
||||
}
|
||||
return DownloadedAttachment.error(statusCode, errorBody);
|
||||
}
|
||||
|
||||
byte[] bytes;
|
||||
try (InputStream inputStream = connection.getInputStream();
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while (inputStream != null && (read = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, read);
|
||||
}
|
||||
bytes = outputStream.toByteArray();
|
||||
}
|
||||
|
||||
String contentType = connection.getHeaderField("Content-Type");
|
||||
String fileName = parseDownloadFileName(connection.getHeaderField("Content-Disposition"));
|
||||
return new DownloadedAttachment(
|
||||
statusCode,
|
||||
fileName == null || fileName.isEmpty() ? fallbackFileName : fileName,
|
||||
contentType == null || contentType.isEmpty() ? fallbackMimeType : contentType,
|
||||
bytes,
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
private String escapeMultipartValue(String value) {
|
||||
if (value == null) {
|
||||
return "attachment";
|
||||
}
|
||||
return value.replace("\"", "%22");
|
||||
}
|
||||
|
||||
private JSONObject readJson(InputStream stream) throws IOException, JSONException {
|
||||
@@ -327,6 +511,35 @@ public class BossApiClient {
|
||||
return new JSONObject(raw);
|
||||
}
|
||||
|
||||
private String readText(InputStream stream) throws IOException {
|
||||
if (stream == null) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
builder.append(line);
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private String parseDownloadFileName(@Nullable String contentDisposition) {
|
||||
if (contentDisposition == null || contentDisposition.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String[] parts = contentDisposition.split(";");
|
||||
for (String part : parts) {
|
||||
String trimmed = part.trim();
|
||||
if (trimmed.startsWith("filename=")) {
|
||||
return trimmed.substring("filename=".length()).replace("\"", "");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void captureSessionCookie(Map<String, List<String>> headers) {
|
||||
if (headers == null) return;
|
||||
List<String> setCookieHeaders = headers.get("Set-Cookie");
|
||||
@@ -403,4 +616,34 @@ public class BossApiClient {
|
||||
return new ApiResponse(statusCode, json);
|
||||
}
|
||||
}
|
||||
|
||||
public static class DownloadedAttachment {
|
||||
public final int statusCode;
|
||||
public final String fileName;
|
||||
public final String mimeType;
|
||||
public final byte[] bytes;
|
||||
public final String errorBody;
|
||||
|
||||
public DownloadedAttachment(
|
||||
int statusCode,
|
||||
String fileName,
|
||||
String mimeType,
|
||||
byte[] bytes,
|
||||
String errorBody
|
||||
) {
|
||||
this.statusCode = statusCode;
|
||||
this.fileName = fileName;
|
||||
this.mimeType = mimeType;
|
||||
this.bytes = bytes == null ? new byte[0] : bytes;
|
||||
this.errorBody = errorBody == null ? "" : errorBody;
|
||||
}
|
||||
|
||||
public boolean ok() {
|
||||
return statusCode >= 200 && statusCode < 300;
|
||||
}
|
||||
|
||||
public static DownloadedAttachment error(int statusCode, String errorBody) {
|
||||
return new DownloadedAttachment(statusCode, "", "application/octet-stream", new byte[0], errorBody);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,26 +673,7 @@ public final class BossUi {
|
||||
boolean outgoing,
|
||||
@Nullable String kindLabel
|
||||
) {
|
||||
LinearLayout wrapper = new LinearLayout(context);
|
||||
wrapper.setOrientation(LinearLayout.VERTICAL);
|
||||
wrapper.setGravity(outgoing ? Gravity.END : Gravity.START);
|
||||
LinearLayout.LayoutParams wrapperParams = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
wrapperParams.bottomMargin = dp(context, 12);
|
||||
wrapper.setLayoutParams(wrapperParams);
|
||||
|
||||
TextView metaView = new TextView(context);
|
||||
String metaText = senderLabel;
|
||||
if (!TextUtils.isEmpty(meta)) {
|
||||
metaText = metaText + " · " + meta;
|
||||
}
|
||||
metaView.setText(metaText);
|
||||
metaView.setTextSize(11);
|
||||
metaView.setTextColor(context.getColor(R.color.boss_text_soft));
|
||||
metaView.setPadding(dp(context, 6), 0, dp(context, 6), dp(context, 4));
|
||||
wrapper.addView(metaView);
|
||||
LinearLayout wrapper = buildMessageWrapper(context, senderLabel, meta, outgoing);
|
||||
|
||||
LinearLayout bubble = new LinearLayout(context);
|
||||
bubble.setOrientation(LinearLayout.VERTICAL);
|
||||
@@ -723,6 +704,128 @@ public final class BossUi {
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
public static LinearLayout buildAttachmentMessageCard(
|
||||
Context context,
|
||||
String senderLabel,
|
||||
String sourceType,
|
||||
String fileName,
|
||||
@Nullable String detail,
|
||||
@Nullable String status,
|
||||
@Nullable String summary,
|
||||
@Nullable String actionLabel,
|
||||
@Nullable View.OnClickListener actionListener,
|
||||
@Nullable String meta,
|
||||
boolean outgoing,
|
||||
@Nullable View.OnClickListener cardClickListener
|
||||
) {
|
||||
LinearLayout wrapper = buildMessageWrapper(context, senderLabel, meta, outgoing);
|
||||
|
||||
LinearLayout card = new LinearLayout(context);
|
||||
card.setOrientation(LinearLayout.VERTICAL);
|
||||
card.setMinimumWidth(dp(context, 180));
|
||||
card.setPadding(dp(context, 14), dp(context, 12), dp(context, 14), dp(context, 12));
|
||||
card.setBackground(createRoundedBackground(
|
||||
outgoing ? Color.parseColor("#CFF0D8") : Color.WHITE,
|
||||
dp(context, 18)
|
||||
));
|
||||
card.setElevation(dp(context, 1));
|
||||
if (cardClickListener != null) {
|
||||
card.setClickable(true);
|
||||
card.setFocusable(true);
|
||||
card.setOnClickListener(cardClickListener);
|
||||
}
|
||||
|
||||
if ("image".equals(sourceType) || "video".equals(sourceType)) {
|
||||
TextView preview = new TextView(context);
|
||||
LinearLayout.LayoutParams previewParams = new LinearLayout.LayoutParams(
|
||||
Math.round(context.getResources().getDisplayMetrics().widthPixels * 0.56f),
|
||||
dp(context, 118)
|
||||
);
|
||||
preview.setLayoutParams(previewParams);
|
||||
preview.setGravity(Gravity.CENTER);
|
||||
preview.setText("image".equals(sourceType) ? "图片预览" : "视频封面");
|
||||
preview.setTextSize(13);
|
||||
preview.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
preview.setTextColor(context.getColor(R.color.boss_text_muted));
|
||||
preview.setBackground(createRoundedBackground(Color.parseColor("#EEF2EE"), dp(context, 14)));
|
||||
card.addView(preview);
|
||||
|
||||
TextView nameView = buildAttachmentPrimaryText(context, fileName);
|
||||
nameView.setPadding(0, dp(context, 10), 0, 0);
|
||||
card.addView(nameView);
|
||||
|
||||
TextView statusView = buildAttachmentSecondaryText(
|
||||
context,
|
||||
TextUtils.isEmpty(status) ? "已发送" : status
|
||||
);
|
||||
statusView.setPadding(0, dp(context, 6), 0, 0);
|
||||
card.addView(statusView);
|
||||
} else {
|
||||
LinearLayout row = new LinearLayout(context);
|
||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||
row.setGravity(Gravity.CENTER_VERTICAL);
|
||||
|
||||
TextView icon = new TextView(context);
|
||||
LinearLayout.LayoutParams iconParams = new LinearLayout.LayoutParams(dp(context, 42), dp(context, 42));
|
||||
icon.setLayoutParams(iconParams);
|
||||
icon.setGravity(Gravity.CENTER);
|
||||
icon.setText("文");
|
||||
icon.setTextSize(15);
|
||||
icon.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
icon.setTextColor(context.getColor(R.color.boss_green));
|
||||
icon.setBackground(createRoundedBackground(Color.parseColor("#E8F6ED"), dp(context, 12)));
|
||||
row.addView(icon);
|
||||
|
||||
LinearLayout texts = new LinearLayout(context);
|
||||
texts.setOrientation(LinearLayout.VERTICAL);
|
||||
LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams(
|
||||
0,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
1f
|
||||
);
|
||||
textParams.leftMargin = dp(context, 12);
|
||||
texts.setLayoutParams(textParams);
|
||||
|
||||
texts.addView(buildAttachmentPrimaryText(context, fileName));
|
||||
texts.addView(buildAttachmentSecondaryText(
|
||||
context,
|
||||
joinAttachmentDetail(detail, status)
|
||||
));
|
||||
row.addView(texts);
|
||||
|
||||
card.addView(row);
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(summary)) {
|
||||
TextView summaryView = buildAttachmentSecondaryText(context, summary);
|
||||
summaryView.setPadding(0, dp(context, 10), 0, 0);
|
||||
summaryView.setTextColor(context.getColor(R.color.boss_text_primary));
|
||||
card.addView(summaryView);
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(actionLabel) && actionListener != null) {
|
||||
Button actionButton = new Button(context);
|
||||
LinearLayout.LayoutParams actionParams = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
dp(context, 34)
|
||||
);
|
||||
actionParams.topMargin = dp(context, 12);
|
||||
actionButton.setLayoutParams(actionParams);
|
||||
actionButton.setMinWidth(dp(context, 88));
|
||||
actionButton.setText(actionLabel);
|
||||
actionButton.setAllCaps(false);
|
||||
actionButton.setTextSize(13);
|
||||
actionButton.setPadding(dp(context, 14), 0, dp(context, 14), 0);
|
||||
actionButton.setBackgroundResource(R.drawable.bg_secondary_button);
|
||||
actionButton.setTextColor(context.getColor(R.color.boss_text_primary));
|
||||
actionButton.setOnClickListener(actionListener);
|
||||
card.addView(actionButton);
|
||||
}
|
||||
|
||||
wrapper.addView(card);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
public static LinearLayout buildForwardSingleBubble(
|
||||
Context context,
|
||||
String senderLabel,
|
||||
@@ -894,6 +997,65 @@ public final class BossUi {
|
||||
return button;
|
||||
}
|
||||
|
||||
private static LinearLayout buildMessageWrapper(
|
||||
Context context,
|
||||
String senderLabel,
|
||||
@Nullable String meta,
|
||||
boolean outgoing
|
||||
) {
|
||||
LinearLayout wrapper = new LinearLayout(context);
|
||||
wrapper.setOrientation(LinearLayout.VERTICAL);
|
||||
wrapper.setGravity(outgoing ? Gravity.END : Gravity.START);
|
||||
LinearLayout.LayoutParams wrapperParams = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
wrapperParams.bottomMargin = dp(context, 12);
|
||||
wrapper.setLayoutParams(wrapperParams);
|
||||
|
||||
TextView metaView = new TextView(context);
|
||||
String metaText = senderLabel;
|
||||
if (!TextUtils.isEmpty(meta)) {
|
||||
metaText = metaText + " · " + meta;
|
||||
}
|
||||
metaView.setText(metaText);
|
||||
metaView.setTextSize(11);
|
||||
metaView.setTextColor(context.getColor(R.color.boss_text_soft));
|
||||
metaView.setPadding(dp(context, 6), 0, dp(context, 6), dp(context, 4));
|
||||
wrapper.addView(metaView);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
private static TextView buildAttachmentPrimaryText(Context context, String text) {
|
||||
TextView primary = new TextView(context);
|
||||
primary.setText(TextUtils.isEmpty(text) ? "未命名附件" : text);
|
||||
primary.setTextSize(15);
|
||||
primary.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
primary.setTextColor(context.getColor(R.color.boss_text_primary));
|
||||
primary.setMaxLines(2);
|
||||
primary.setEllipsize(TextUtils.TruncateAt.END);
|
||||
return primary;
|
||||
}
|
||||
|
||||
private static TextView buildAttachmentSecondaryText(Context context, String text) {
|
||||
TextView secondary = new TextView(context);
|
||||
secondary.setText(TextUtils.isEmpty(text) ? "已发送" : text);
|
||||
secondary.setTextSize(13);
|
||||
secondary.setTextColor(context.getColor(R.color.boss_text_muted));
|
||||
secondary.setPadding(0, dp(context, 6), 0, 0);
|
||||
return secondary;
|
||||
}
|
||||
|
||||
private static String joinAttachmentDetail(@Nullable String detail, @Nullable String status) {
|
||||
if (TextUtils.isEmpty(detail)) {
|
||||
return TextUtils.isEmpty(status) ? "已发送" : status;
|
||||
}
|
||||
if (TextUtils.isEmpty(status)) {
|
||||
return detail;
|
||||
}
|
||||
return detail + " · " + status;
|
||||
}
|
||||
|
||||
public static EditText buildInput(Context context, String hint, boolean multiline) {
|
||||
EditText input = new EditText(context);
|
||||
input.setHint(hint);
|
||||
|
||||
@@ -60,6 +60,10 @@ public final class ProjectChatUiState {
|
||||
return !sending && text != null && !text.trim().isEmpty();
|
||||
}
|
||||
|
||||
public static boolean requiresAttachmentConfirmation(@Nullable String sourceType) {
|
||||
return "image".equals(sourceType) || "video".equals(sourceType);
|
||||
}
|
||||
|
||||
public static boolean shouldAutoScroll(boolean nearBottom, boolean forced) {
|
||||
return nearBottom || forced;
|
||||
}
|
||||
@@ -162,6 +166,65 @@ public final class ProjectChatUiState {
|
||||
return truncate(lastBody, 28);
|
||||
}
|
||||
|
||||
public static String labelForAttachmentAnalysisState(@Nullable String analysisState) {
|
||||
if ("queued_auto".equals(analysisState)) {
|
||||
return "自动分析排队中";
|
||||
}
|
||||
if ("ready_manual".equals(analysisState)) {
|
||||
return "待分析";
|
||||
}
|
||||
if ("processing".equals(analysisState)) {
|
||||
return "AI 分析中";
|
||||
}
|
||||
if ("completed".equals(analysisState)) {
|
||||
return "已分析";
|
||||
}
|
||||
if ("failed".equals(analysisState)) {
|
||||
return "分析失败";
|
||||
}
|
||||
return "已发送";
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String actionLabelForAttachmentAnalysisState(@Nullable String analysisState) {
|
||||
if ("ready_manual".equals(analysisState)) {
|
||||
return "让 AI 分析";
|
||||
}
|
||||
if ("failed".equals(analysisState)) {
|
||||
return "重试分析";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String labelForAttachmentKind(@Nullable String attachmentKind) {
|
||||
if ("image".equals(attachmentKind)) {
|
||||
return "图片";
|
||||
}
|
||||
if ("video".equals(attachmentKind)) {
|
||||
return "视频";
|
||||
}
|
||||
if ("pdf".equals(attachmentKind)) {
|
||||
return "PDF";
|
||||
}
|
||||
if ("office".equals(attachmentKind)) {
|
||||
return "文档";
|
||||
}
|
||||
if ("text".equals(attachmentKind)) {
|
||||
return "文本";
|
||||
}
|
||||
return "文件";
|
||||
}
|
||||
|
||||
public static String formatAttachmentSize(long fileSizeBytes) {
|
||||
if (fileSizeBytes >= 1024L * 1024L) {
|
||||
return String.format(java.util.Locale.US, "%.1f MB", fileSizeBytes / (1024f * 1024f));
|
||||
}
|
||||
if (fileSizeBytes >= 1024L) {
|
||||
return Math.max(1, Math.round(fileSizeBytes / 1024f)) + " KB";
|
||||
}
|
||||
return Math.max(fileSizeBytes, 0L) + " B";
|
||||
}
|
||||
|
||||
private static boolean isBlank(@Nullable String value) {
|
||||
return value == null || value.trim().isEmpty();
|
||||
}
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.text.Editable;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.content.FileProvider;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -35,6 +46,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
private LinearLayout quickActionsLayout;
|
||||
private LinearLayout composerRow;
|
||||
private LinearLayout multiSelectActionsLayout;
|
||||
private Button composerAttachmentButton;
|
||||
private EditText composerInput;
|
||||
private Button composerSendButton;
|
||||
private Button multiSelectForwardButton;
|
||||
@@ -49,6 +61,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
private ProjectChatUiState.SelectionState selectionState = ProjectChatUiState.emptySelection();
|
||||
private ActivityResultLauncher<Intent> conversationInfoLauncher;
|
||||
private ActivityResultLauncher<Intent> forwardTargetLauncher;
|
||||
private ActivityResultLauncher<String> imagePickerLauncher;
|
||||
private ActivityResultLauncher<String> videoPickerLauncher;
|
||||
private ActivityResultLauncher<String> filePickerLauncher;
|
||||
|
||||
static final class ChromeBindings {
|
||||
final boolean multiSelecting;
|
||||
@@ -100,6 +115,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
quickActionsLayout = findViewById(R.id.project_chat_quick_actions);
|
||||
composerRow = findViewById(R.id.project_chat_composer_row);
|
||||
multiSelectActionsLayout = findViewById(R.id.project_chat_multi_select_actions);
|
||||
composerAttachmentButton = findViewById(R.id.project_chat_attach);
|
||||
composerInput = findViewById(R.id.project_chat_input);
|
||||
composerSendButton = findViewById(R.id.project_chat_send);
|
||||
multiSelectForwardButton = findViewById(R.id.project_chat_multi_forward);
|
||||
@@ -131,8 +147,23 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
reload(true);
|
||||
}
|
||||
);
|
||||
imagePickerLauncher = registerForActivityResult(
|
||||
new ActivityResultContracts.GetContent(),
|
||||
uri -> onAttachmentPicked(uri, "image")
|
||||
);
|
||||
videoPickerLauncher = registerForActivityResult(
|
||||
new ActivityResultContracts.GetContent(),
|
||||
uri -> onAttachmentPicked(uri, "video")
|
||||
);
|
||||
filePickerLauncher = registerForActivityResult(
|
||||
new ActivityResultContracts.GetContent(),
|
||||
uri -> onAttachmentPicked(uri, "file")
|
||||
);
|
||||
|
||||
updateProjectHeader(initialProjectName == null ? "项目详情" : initialProjectName, "正在同步项目详情...");
|
||||
if (composerAttachmentButton != null) {
|
||||
composerAttachmentButton.setOnClickListener(v -> showAttachmentEntrySheet());
|
||||
}
|
||||
composerSendButton.setOnClickListener(v -> sendTextMessageFromComposer());
|
||||
multiSelectForwardButton.setOnClickListener(v -> {
|
||||
if (!ProjectChatUiState.canForwardSelection(selectionState)) {
|
||||
@@ -203,6 +234,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
@Override
|
||||
protected void setRefreshing(boolean refreshing) {
|
||||
super.setRefreshing(refreshing);
|
||||
if (composerAttachmentButton != null) {
|
||||
composerAttachmentButton.setEnabled(!refreshing && !composerSending);
|
||||
}
|
||||
if (composerInput != null) {
|
||||
composerInput.setEnabled(!refreshing);
|
||||
}
|
||||
@@ -297,6 +331,99 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
sendProjectMessage("text", body);
|
||||
}
|
||||
|
||||
private void showAttachmentEntrySheet() {
|
||||
if (isComposerBusy()) {
|
||||
return;
|
||||
}
|
||||
Dialog dialog = new Dialog(this);
|
||||
LinearLayout sheet = new LinearLayout(this);
|
||||
sheet.setOrientation(LinearLayout.VERTICAL);
|
||||
sheet.setPadding(BossUi.dp(this, 18), BossUi.dp(this, 12), BossUi.dp(this, 18), BossUi.dp(this, 18));
|
||||
|
||||
TextView title = new TextView(this);
|
||||
title.setText("发送附件");
|
||||
title.setTextSize(16);
|
||||
title.setTypeface(android.graphics.Typeface.DEFAULT_BOLD);
|
||||
title.setTextColor(getColor(R.color.boss_text_primary));
|
||||
title.setPadding(0, 0, 0, BossUi.dp(this, 12));
|
||||
sheet.addView(title);
|
||||
|
||||
sheet.addView(buildAttachmentSheetAction("图片", () -> imagePickerLauncher.launch("image/*"), dialog));
|
||||
sheet.addView(buildAttachmentSheetAction("视频", () -> videoPickerLauncher.launch("video/*"), dialog));
|
||||
sheet.addView(buildAttachmentSheetAction("文件", () -> filePickerLauncher.launch("*/*"), dialog));
|
||||
|
||||
dialog.setContentView(sheet);
|
||||
dialog.show();
|
||||
if (dialog.getWindow() != null) {
|
||||
dialog.getWindow().setLayout(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
dialog.getWindow().setGravity(Gravity.BOTTOM);
|
||||
}
|
||||
}
|
||||
|
||||
private View buildAttachmentSheetAction(
|
||||
String label,
|
||||
Runnable action,
|
||||
Dialog dialog
|
||||
) {
|
||||
Button button = new Button(this);
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
BossUi.dp(this, 48)
|
||||
);
|
||||
params.bottomMargin = BossUi.dp(this, 8);
|
||||
button.setLayoutParams(params);
|
||||
button.setBackgroundResource(R.drawable.bg_secondary_button);
|
||||
button.setAllCaps(false);
|
||||
button.setGravity(Gravity.CENTER_VERTICAL | Gravity.START);
|
||||
button.setPadding(BossUi.dp(this, 16), 0, BossUi.dp(this, 16), 0);
|
||||
button.setText(label);
|
||||
button.setTextSize(15);
|
||||
button.setTextColor(getColor(R.color.boss_text_primary));
|
||||
button.setOnClickListener(v -> {
|
||||
dialog.dismiss();
|
||||
action.run();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
private void onAttachmentPicked(@Nullable Uri uri, String sourceType) {
|
||||
if (uri == null) {
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
AttachmentComposerState.PendingAttachment attachment = readPendingAttachment(uri, sourceType);
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
if (attachment.requiresConfirmation()) {
|
||||
showAttachmentConfirmDialog(attachment);
|
||||
} else {
|
||||
uploadAttachment(attachment);
|
||||
}
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("读取附件失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void showAttachmentConfirmDialog(AttachmentComposerState.PendingAttachment attachment) {
|
||||
String actionLabel = "image".equals(attachment.sourceType) ? "图片" : "视频";
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("发送" + actionLabel)
|
||||
.setMessage(attachment.fileName + " · " + ProjectChatUiState.formatAttachmentSize(attachment.fileSizeBytes))
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("发送", (dialog, which) -> uploadAttachment(attachment))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void sendProjectMessage(String kind, String body) {
|
||||
composerSending = true;
|
||||
updateComposerSendButtonState();
|
||||
@@ -327,6 +454,56 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void uploadAttachment(AttachmentComposerState.PendingAttachment attachment) {
|
||||
composerSending = true;
|
||||
updateComposerSendButtonState();
|
||||
setRefreshing(true);
|
||||
appendPendingOutgoingAttachment(attachment);
|
||||
scrollChatToBottom();
|
||||
executor.execute(() -> {
|
||||
try (InputStream inputStream = openAttachmentInputStream(attachment)) {
|
||||
BossApiClient.ApiResponse uploadResponse = apiClient.uploadAttachment(
|
||||
projectId,
|
||||
attachment.fileName,
|
||||
attachment.mimeType,
|
||||
inputStream,
|
||||
attachment.sourceType
|
||||
);
|
||||
if (!uploadResponse.ok()) {
|
||||
throw new IllegalStateException(uploadResponse.message());
|
||||
}
|
||||
|
||||
String successMessage = "附件已发送";
|
||||
JSONObject uploadedAttachment = uploadResponse.json.optJSONObject("attachment");
|
||||
if (uploadedAttachment != null) {
|
||||
String analysisState = uploadedAttachment.optString("analysisState", "");
|
||||
if ("queued_auto".equals(analysisState)) {
|
||||
successMessage = "附件已发送,自动分析排队中";
|
||||
} else if ("ready_manual".equals(analysisState)) {
|
||||
successMessage = "附件已发送,可手动发起分析";
|
||||
} else if ("failed".equals(analysisState)) {
|
||||
successMessage = "附件已发送,分析稍后可重试";
|
||||
}
|
||||
}
|
||||
|
||||
String finalSuccessMessage = successMessage;
|
||||
runOnUiThread(() -> {
|
||||
composerSending = false;
|
||||
showMessage(finalSuccessMessage);
|
||||
reload(true);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
composerSending = false;
|
||||
setRefreshing(false);
|
||||
removePendingOutgoingBubble();
|
||||
showMessage("附件发送失败:" + error.getMessage());
|
||||
updateComposerSendButtonState();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void openGoals() {
|
||||
Intent intent = new Intent(this, ProjectGoalsActivity.class);
|
||||
intent.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, projectId);
|
||||
@@ -361,7 +538,18 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
boolean outgoing = isOutgoingMessage(senderLabel);
|
||||
|
||||
View messageView;
|
||||
View.OnClickListener messagePrimaryClick = null;
|
||||
switch (kind) {
|
||||
case "attachment":
|
||||
messageView = buildAttachmentMessageView(message, senderLabel, meta, outgoing);
|
||||
JSONObject attachment = firstAttachment(message);
|
||||
if (attachment != null) {
|
||||
String attachmentId = attachment.optString("attachmentId", "");
|
||||
if (!TextUtils.isEmpty(attachmentId)) {
|
||||
messagePrimaryClick = v -> openAttachment(attachment);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "forward_single":
|
||||
messageView = BossUi.buildForwardSingleBubble(
|
||||
this,
|
||||
@@ -393,11 +581,62 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
);
|
||||
break;
|
||||
}
|
||||
bindMessageInteractions(messageView, messageId, body);
|
||||
bindMessageInteractions(messageView, messageId, body, messagePrimaryClick);
|
||||
return messageView;
|
||||
}
|
||||
|
||||
private void bindMessageInteractions(View messageView, String messageId, String body) {
|
||||
private View buildAttachmentMessageView(
|
||||
JSONObject message,
|
||||
String senderLabel,
|
||||
String meta,
|
||||
boolean outgoing
|
||||
) {
|
||||
JSONObject attachment = firstAttachment(message);
|
||||
if (attachment == null) {
|
||||
return BossUi.buildMessageBubble(
|
||||
this,
|
||||
senderLabel,
|
||||
message.optString("body", "已发送附件"),
|
||||
meta,
|
||||
outgoing,
|
||||
"附件"
|
||||
);
|
||||
}
|
||||
String sourceType = resolveAttachmentSourceType(
|
||||
attachment.optString("attachmentKind", ""),
|
||||
attachment.optString("mimeType", "")
|
||||
);
|
||||
String detail = ("image".equals(sourceType) || "video".equals(sourceType))
|
||||
? null
|
||||
: ProjectChatUiState.formatAttachmentSize(attachment.optLong("fileSizeBytes", 0L));
|
||||
String analysisState = attachment.optString("analysisState", "");
|
||||
String attachmentId = attachment.optString("attachmentId", "");
|
||||
String actionLabel = ProjectChatUiState.actionLabelForAttachmentAnalysisState(analysisState);
|
||||
View.OnClickListener actionListener = TextUtils.isEmpty(actionLabel) || TextUtils.isEmpty(attachmentId)
|
||||
? null
|
||||
: v -> requestAttachmentAnalysis(attachmentId, attachment.optString("fileName", "附件"));
|
||||
return BossUi.buildAttachmentMessageCard(
|
||||
this,
|
||||
senderLabel,
|
||||
sourceType,
|
||||
attachment.optString("fileName", "attachment"),
|
||||
detail,
|
||||
ProjectChatUiState.labelForAttachmentAnalysisState(analysisState),
|
||||
attachment.optString("analysisSummary", ""),
|
||||
actionLabel,
|
||||
actionListener,
|
||||
meta,
|
||||
outgoing,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private void bindMessageInteractions(
|
||||
View messageView,
|
||||
String messageId,
|
||||
String body,
|
||||
@Nullable View.OnClickListener defaultClickListener
|
||||
) {
|
||||
if (messageView == null || TextUtils.isEmpty(messageId)) {
|
||||
return;
|
||||
}
|
||||
@@ -406,6 +645,9 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
messageView.setLongClickable(true);
|
||||
messageView.setOnClickListener(v -> {
|
||||
if (!selectionState.multiSelecting) {
|
||||
if (defaultClickListener != null) {
|
||||
defaultClickListener.onClick(v);
|
||||
}
|
||||
return;
|
||||
}
|
||||
toggleMultiSelectMessage(messageId);
|
||||
@@ -611,6 +853,35 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
appendContent(pendingOutgoingBubble);
|
||||
}
|
||||
|
||||
private void appendPendingOutgoingAttachment(AttachmentComposerState.PendingAttachment attachment) {
|
||||
if (contentLayout == null) {
|
||||
return;
|
||||
}
|
||||
removePendingOutgoingBubble();
|
||||
if (contentLayout.getChildCount() == 1 && contentLayout.getChildAt(0) instanceof android.widget.TextView) {
|
||||
contentLayout.removeAllViews();
|
||||
}
|
||||
String senderLabel = TextUtils.isEmpty(apiClient.getDisplayName()) ? "你" : apiClient.getDisplayName();
|
||||
String detail = attachment.requiresConfirmation()
|
||||
? null
|
||||
: ProjectChatUiState.formatAttachmentSize(attachment.fileSizeBytes);
|
||||
pendingOutgoingBubble = BossUi.buildAttachmentMessageCard(
|
||||
this,
|
||||
senderLabel,
|
||||
attachment.sourceType,
|
||||
attachment.fileName,
|
||||
detail,
|
||||
"发送中",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
null
|
||||
);
|
||||
appendContent(pendingOutgoingBubble);
|
||||
}
|
||||
|
||||
private void removePendingOutgoingBubble() {
|
||||
if (pendingOutgoingBubble != null && pendingOutgoingBubble.getParent() != null && contentLayout != null) {
|
||||
contentLayout.removeView(pendingOutgoingBubble);
|
||||
@@ -707,6 +978,189 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
return message.optString("body", "转发的聊天记录");
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private JSONObject firstAttachment(JSONObject message) {
|
||||
JSONArray attachments = message.optJSONArray("attachments");
|
||||
if (attachments == null || attachments.length() == 0) {
|
||||
return null;
|
||||
}
|
||||
return attachments.optJSONObject(0);
|
||||
}
|
||||
|
||||
private String resolveAttachmentSourceType(String attachmentKind, String mimeType) {
|
||||
if ("image".equals(attachmentKind) || (!TextUtils.isEmpty(mimeType) && mimeType.startsWith("image/"))) {
|
||||
return "image";
|
||||
}
|
||||
if ("video".equals(attachmentKind) || (!TextUtils.isEmpty(mimeType) && mimeType.startsWith("video/"))) {
|
||||
return "video";
|
||||
}
|
||||
return "file";
|
||||
}
|
||||
|
||||
private AttachmentComposerState.PendingAttachment readPendingAttachment(Uri uri, String sourceType) throws Exception {
|
||||
String fileName = resolveAttachmentFileName(uri);
|
||||
String mimeType = resolveAttachmentMimeType(uri, sourceType);
|
||||
long fileSize = resolveAttachmentFileSize(uri, 0L);
|
||||
return new AttachmentComposerState.PendingAttachment(sourceType, fileName, mimeType, fileSize, uri);
|
||||
}
|
||||
|
||||
private String resolveAttachmentFileName(Uri uri) {
|
||||
String fallback = uri.getLastPathSegment();
|
||||
try (Cursor cursor = getContentResolver().query(
|
||||
uri,
|
||||
new String[]{OpenableColumns.DISPLAY_NAME},
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
int columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||
if (columnIndex >= 0) {
|
||||
String displayName = cursor.getString(columnIndex);
|
||||
if (!TextUtils.isEmpty(displayName)) {
|
||||
return displayName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return TextUtils.isEmpty(fallback) ? "attachment" : fallback;
|
||||
}
|
||||
|
||||
private long resolveAttachmentFileSize(Uri uri, long fallback) {
|
||||
try (Cursor cursor = getContentResolver().query(
|
||||
uri,
|
||||
new String[]{OpenableColumns.SIZE},
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
int columnIndex = cursor.getColumnIndex(OpenableColumns.SIZE);
|
||||
if (columnIndex >= 0) {
|
||||
long value = cursor.getLong(columnIndex);
|
||||
if (value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private String resolveAttachmentMimeType(Uri uri, String sourceType) {
|
||||
String mimeType = getContentResolver().getType(uri);
|
||||
if (!TextUtils.isEmpty(mimeType)) {
|
||||
return mimeType;
|
||||
}
|
||||
if ("image".equals(sourceType)) {
|
||||
return "image/*";
|
||||
}
|
||||
if ("video".equals(sourceType)) {
|
||||
return "video/*";
|
||||
}
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
private InputStream openAttachmentInputStream(AttachmentComposerState.PendingAttachment attachment) throws Exception {
|
||||
if (attachment.uri == null) {
|
||||
throw new IllegalStateException("附件来源不存在");
|
||||
}
|
||||
InputStream inputStream = getContentResolver().openInputStream(attachment.uri);
|
||||
if (inputStream == null) {
|
||||
throw new IllegalStateException("无法打开附件输入流");
|
||||
}
|
||||
return inputStream;
|
||||
}
|
||||
|
||||
private void requestAttachmentAnalysis(String attachmentId, String fileName) {
|
||||
if (TextUtils.isEmpty(attachmentId)) {
|
||||
showMessage("缺少附件标识");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.analyzeAttachment(projectId, attachmentId);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showMessage("已发起分析:" + fileName);
|
||||
reload(false);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("发起分析失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void openAttachment(JSONObject attachment) {
|
||||
String attachmentId = attachment.optString("attachmentId", "");
|
||||
if (TextUtils.isEmpty(attachmentId)) {
|
||||
showMessage("缺少附件标识");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.DownloadedAttachment downloaded = apiClient.downloadAttachment(
|
||||
attachmentId,
|
||||
attachment.optString("fileName", "attachment"),
|
||||
attachment.optString("mimeType", "application/octet-stream")
|
||||
);
|
||||
if (!downloaded.ok()) {
|
||||
throw new IllegalStateException(
|
||||
TextUtils.isEmpty(downloaded.errorBody)
|
||||
? "DOWNLOAD_FAILED"
|
||||
: downloaded.errorBody
|
||||
);
|
||||
}
|
||||
File file = cacheDownloadedAttachment(downloaded.fileName, downloaded.bytes);
|
||||
runOnUiThread(() -> openDownloadedAttachment(file, downloaded.mimeType));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("打开附件失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private File cacheDownloadedAttachment(String fileName, byte[] bytes) throws Exception {
|
||||
File downloadsDir = new File(getCacheDir(), "attachment-downloads");
|
||||
if (!downloadsDir.exists() && !downloadsDir.mkdirs()) {
|
||||
throw new IllegalStateException("无法创建附件缓存目录");
|
||||
}
|
||||
File file = new File(downloadsDir, sanitizeAttachmentFileName(fileName));
|
||||
try (FileOutputStream outputStream = new FileOutputStream(file, false)) {
|
||||
outputStream.write(bytes);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private String sanitizeAttachmentFileName(String fileName) {
|
||||
String trimmed = TextUtils.isEmpty(fileName) ? "attachment" : fileName.trim();
|
||||
return trimmed.replace('/', '_').replace('\\', '_');
|
||||
}
|
||||
|
||||
private void openDownloadedAttachment(File file, String mimeType) {
|
||||
Uri uri = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID + ".fileprovider", file);
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW);
|
||||
intent.setDataAndType(uri, TextUtils.isEmpty(mimeType) ? "application/octet-stream" : mimeType);
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
try {
|
||||
startActivity(Intent.createChooser(intent, "打开附件"));
|
||||
} catch (ActivityNotFoundException error) {
|
||||
showMessage("系统中没有可打开该附件的应用");
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
static ChromeBindings buildChromeBindings(
|
||||
ProjectChatUiState.ChromeState chromeState,
|
||||
boolean composerBusy
|
||||
|
||||
@@ -135,6 +135,19 @@
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/project_chat_attach"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:text="+"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/project_chat_input"
|
||||
android:layout_width="0dp"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class AttachmentComposerStateTest {
|
||||
@Test
|
||||
public void imageAttachments_requireConfirmationBeforeSending() {
|
||||
AttachmentComposerState.PendingAttachment attachment =
|
||||
new AttachmentComposerState.PendingAttachment(
|
||||
"image",
|
||||
"现场照片.png",
|
||||
"image/png",
|
||||
4096L,
|
||||
null
|
||||
);
|
||||
|
||||
assertTrue(ProjectChatUiState.requiresAttachmentConfirmation(attachment.sourceType));
|
||||
assertTrue(attachment.requiresConfirmation());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void videoAttachments_requireConfirmationBeforeSending() {
|
||||
AttachmentComposerState.PendingAttachment attachment =
|
||||
new AttachmentComposerState.PendingAttachment(
|
||||
"video",
|
||||
"巡检录屏.mp4",
|
||||
"video/mp4",
|
||||
8192L,
|
||||
null
|
||||
);
|
||||
|
||||
assertTrue(ProjectChatUiState.requiresAttachmentConfirmation(attachment.sourceType));
|
||||
assertTrue(attachment.requiresConfirmation());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fileAttachments_doNotRequireConfirmation() {
|
||||
AttachmentComposerState.PendingAttachment attachment =
|
||||
new AttachmentComposerState.PendingAttachment(
|
||||
"file",
|
||||
"日报.xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
16384L,
|
||||
null
|
||||
);
|
||||
|
||||
assertFalse(ProjectChatUiState.requiresAttachmentConfirmation(attachment.sourceType));
|
||||
assertFalse(attachment.requiresConfirmation());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.ProtocolException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public class BossApiClientAttachmentTest {
|
||||
@Test
|
||||
public void uploadAttachment_postsMultipartBodyWithSourceType() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/projects/project-1/attachments")
|
||||
);
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.uploadAttachment(
|
||||
"project-1",
|
||||
"现场照片.png",
|
||||
"image/png",
|
||||
new byte[] {1, 2, 3, 4},
|
||||
"image"
|
||||
);
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/project-1/attachments", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertTrue(connection.contentTypeValue.startsWith("multipart/form-data; boundary="));
|
||||
assertTrue(connection.requestBody().contains("name=\"sourceType\""));
|
||||
assertTrue(connection.requestBody().contains("\r\nimage\r\n"));
|
||||
assertTrue(connection.requestBody().contains("name=\"file\"; filename=\"现场照片.png\""));
|
||||
assertTrue(connection.requestBody().contains("Content-Type: image/png"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void analyzeAttachment_postsToAnalyzeEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/projects/project-1/attachments/att-1/analyze")
|
||||
);
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.analyzeAttachment("project-1", "att-1");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/project-1/attachments/att-1/analyze", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals("{}", connection.requestBody());
|
||||
}
|
||||
|
||||
private static final class RecordingBossApiClient extends BossApiClient {
|
||||
private final RecordingConnection connection;
|
||||
private String lastPath = "";
|
||||
|
||||
RecordingBossApiClient(RecordingConnection connection) {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) {
|
||||
lastPath = path;
|
||||
return connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
String encode(String value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
void rememberIdentity(JSONObject json) {
|
||||
// JVM 单测不需要落 Android 侧身份缓存。
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingConnection extends HttpURLConnection {
|
||||
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
|
||||
private final Map<String, String> requestHeaders = new HashMap<>();
|
||||
private String requestMethodValue = "GET";
|
||||
private String contentTypeValue = "";
|
||||
|
||||
RecordingConnection(URL url) {
|
||||
super(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disconnect() {}
|
||||
|
||||
@Override
|
||||
public boolean usingProxy() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connect() {}
|
||||
|
||||
@Override
|
||||
public void setRequestMethod(String method) throws ProtocolException {
|
||||
requestMethodValue = method;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setRequestProperty(String key, String value) {
|
||||
requestHeaders.put(key, value);
|
||||
if ("Content-Type".equalsIgnoreCase(key)) {
|
||||
contentTypeValue = value;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRequestProperty(String key) {
|
||||
return requestHeaders.get(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutputStream getOutputStream() {
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getResponseCode() {
|
||||
return 200;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, List<String>> getHeaderFields() {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
String requestBody() {
|
||||
return requestBody.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class InMemorySharedPreferences implements SharedPreferences {
|
||||
private final Map<String, String> values = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public Map<String, ?> getAll() {
|
||||
return Collections.unmodifiableMap(values);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getString(String key, String defValue) {
|
||||
return values.getOrDefault(key, defValue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getStringSet(String key, Set<String> defValues) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getInt(String key, int defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getLong(String key, long defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloat(String key, float defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getBoolean(String key, boolean defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(String key) {
|
||||
return values.containsKey(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor edit() {
|
||||
return new Editor() {
|
||||
@Override
|
||||
public Editor putString(String key, String value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor remove(String key) {
|
||||
values.remove(key);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor clear() {
|
||||
values.clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply() {}
|
||||
|
||||
@Override
|
||||
public boolean commit() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putStringSet(String key, Set<String> values) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putInt(String key, int value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putLong(String key, long value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putFloat(String key, float value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putBoolean(String key, boolean value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
|
||||
|
||||
@Override
|
||||
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,14 @@ import static org.junit.Assert.assertTrue;
|
||||
import android.content.Intent;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ListView;
|
||||
import android.widget.TextView;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
@@ -75,6 +78,42 @@ public class ProjectDetailActivityUiTest {
|
||||
assertEquals(View.VISIBLE, refreshButton.getVisibility());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void manualAnalysisAttachmentShowsActionChip() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
JSONObject attachment = new JSONObject()
|
||||
.put("attachmentId", "att-1")
|
||||
.put("fileName", "巡检录像.mp4")
|
||||
.put("mimeType", "video/mp4")
|
||||
.put("attachmentKind", "video")
|
||||
.put("analysisState", "ready_manual")
|
||||
.put("fileSizeBytes", 2048);
|
||||
JSONObject message = new JSONObject()
|
||||
.put("id", "msg-1")
|
||||
.put("kind", "attachment")
|
||||
.put("body", "已发送附件")
|
||||
.put("attachments", new JSONArray().put(attachment));
|
||||
|
||||
View attachmentView = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"buildAttachmentMessageView",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, message),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "你"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "09:26"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, true)
|
||||
);
|
||||
|
||||
assertTrue(viewTreeContainsText(attachmentView, "让 AI 分析"));
|
||||
assertTrue(viewTreeContainsText(attachmentView, "待分析"));
|
||||
}
|
||||
|
||||
private static View buildBoundMessageView(TestProjectDetailActivity activity, String messageId, String body) {
|
||||
TextView messageView = new TextView(activity);
|
||||
messageView.setText(body);
|
||||
@@ -84,11 +123,31 @@ public class ProjectDetailActivityUiTest {
|
||||
"bindMessageInteractions",
|
||||
ReflectionHelpers.ClassParameter.from(View.class, messageView),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, messageId),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, body)
|
||||
ReflectionHelpers.ClassParameter.from(String.class, body),
|
||||
ReflectionHelpers.ClassParameter.from(View.OnClickListener.class, null)
|
||||
);
|
||||
return messageView;
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
if (expectedText.contentEquals(text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return false;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static class TestProjectDetailActivity extends ProjectDetailActivity {
|
||||
@Override
|
||||
boolean shouldLoadOnCreate() {
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
- `src/lib/boss-device-auth.ts`:设备 token / 登录会话混合鉴权辅助
|
||||
- `src/lib/boss-events.ts`:SSE 事件总线
|
||||
- `src/lib/boss-master-agent.ts`:主 Agent 真实回复链路、Master Codex Node relay 与 API 容灾逻辑
|
||||
- `src/lib/boss-attachments.ts`:附件类型识别、分析状态决策和下载头
|
||||
- `src/lib/boss-storage.ts`:附件存储抽象、配置校验和脱敏输出
|
||||
- `src/lib/boss-storage-server-file.ts`:服务器文件存储上传 / 读取
|
||||
- `src/lib/boss-storage-aliyun-oss.ts`:阿里 OSS 私有桶上传 / 签名下载
|
||||
- `src/lib/boss-ota.ts`:APK OTA 产物定位与元数据读取
|
||||
- `src/lib/boss-projections.ts`:当前聚合 BFF 投影视图
|
||||
- `src/components/app-runtime.tsx`:APP 日志桥、SSE 刷新和 Skill 面板
|
||||
@@ -51,6 +55,7 @@
|
||||
- `android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java`:原生群资料页,支持群名修改与成员查看
|
||||
- `android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java`:原生独立群聊创建页
|
||||
- `android/app/src/main/java/com/hyzq/boss/ForwardTargetActivity.java`:原生微信式会话选择页,承接单条转发与多选合并转发
|
||||
- `android/app/src/main/java/com/hyzq/boss/AttachmentComposerState.java`:原生附件发送确认规则与待上传附件模型
|
||||
- `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`:原生设备详情与技能入口
|
||||
- `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`:原生 AI 账号管理页
|
||||
- `android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`:原生运维 / 审计中心
|
||||
@@ -75,6 +80,11 @@
|
||||
- `GET /api/v1/projects/master-agent` 正常,主 Agent 项目页已能看到 APP 实时日志
|
||||
- `GET /api/v1/accounts` 正常,已返回主 GPT / 备用 GPT / API 容灾摘要
|
||||
- `GET /api/v1/devices/mac-studio/skills` 正常
|
||||
- `GET /api/v1/storage/config` 正常,已返回当前登录用户的附件存储模式和脱敏 OSS 摘要
|
||||
- `POST /api/v1/storage/config/validate` 正常,已验证可校验并保存阿里 OSS 私有桶配置
|
||||
- `POST /api/v1/projects/[projectId]/attachments` 正常,已支持图片 / 视频 / 文件上传与附件消息写入
|
||||
- `POST /api/v1/projects/[projectId]/attachments/[attachmentId]/analyze` 正常,已支持手动触发主 Agent 附件分析
|
||||
- `GET /api/v1/attachments/[attachmentId]/download` 正常,已支持会话鉴权下载和 task token 下载
|
||||
- `POST /api/auth/login` 正常,会写入 `boss_session`
|
||||
- `boss_session` 当前默认保持 30 天
|
||||
- `GET /api/auth/session` 正常
|
||||
@@ -118,6 +128,8 @@
|
||||
- 线程改名当前遵循微信最新逻辑:从聊天页右上角进入会话信息页,再进行改名
|
||||
- 当前已支持从单线程会话发起独立群聊:原会话保留,新群聊自动命名并可在群资料页改名
|
||||
- 当前已支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`,单条消息转发显示为普通转发消息,多条消息转发显示为聊天记录卡片
|
||||
- 当前已支持聊天附件主链:原生聊天框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;图片 / PDF / 文本默认自动进入主 Agent 附件分析,视频 / Office / 大文件默认手动触发
|
||||
- 当前附件与存储配置页位于 `我的 > 附件与存储`:默认使用服务器文件存储,用户可按账号切到阿里 OSS 私有桶;下载链会优先使用附件上传时固化的 OSS 快照,避免用户后续改配置后旧附件失效
|
||||
- 主 Agent 项目页会实时吸收 APP 端日志,用于边对话边指导 APK / Web 优化
|
||||
- 移动端 UI 已去掉假的状态栏与桌面预览壳;底部一级导航固定在视口底部,返回逻辑不会再把 APP 根页直接弹回桌面
|
||||
- `项目目标` 支持用户编辑、主 Agent 复核、完成项自动划线
|
||||
@@ -139,7 +151,7 @@
|
||||
- 邮件:`Postfix + Dovecot`
|
||||
- Android:`AppCompatActivity + 原生 XML 布局 + HttpURLConnection`
|
||||
- 原生登录恢复:`SharedPreferences + restore token`
|
||||
- 当前最新原生 APK:`2.4.0`(`versionCode=12`)
|
||||
- 当前最新原生 APK:`2.5.0`(`versionCode=13`)
|
||||
|
||||
当前不要误判成已经用了:
|
||||
|
||||
@@ -193,7 +205,7 @@ npm run apk:debug
|
||||
- APP 实时日志同步、主 Agent 日志镜像、SSE 自动刷新和 Skill 同步页已经接通,但日志检索、告警和远程 Skill 管理仍未做
|
||||
- 数据库尚未替代文件存储
|
||||
- 域名入口的代理 / 分裂 DNS 结构仍未完全摸清
|
||||
- 图片 / 视频真实文件上传仍未接对象存储
|
||||
- 当前只支持服务器文件存储和阿里 OSS,尚未接更多对象存储或更丰富的附件详情页
|
||||
- 认证没有真实 session 和令牌吊销
|
||||
|
||||
## 9. 继续开发时的工作原则
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
- 当前已经支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`
|
||||
- 当前多选模式会切换成微信式 `取消 + 已选数量 + 底部转发` 状态
|
||||
- 当前统一使用 `ForwardTargetActivity` 选择目标会话,替换旧的备注转发主链
|
||||
- 当前已支持聊天附件主链:输入框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;图片 / 视频先确认,文件直接发送
|
||||
- 当前附件消息支持下载、原生打开、手动分析和自动分析状态展示
|
||||
- `线程详情 / 运维调试` 仍保留对应原生活动页,但已退出主聊天面
|
||||
- 当前已补上本地发送中气泡、发送按钮状态控制,以及“只有接近底部才自动滚到底”的消息流行为
|
||||
- 当前根页导航:
|
||||
@@ -124,6 +126,7 @@
|
||||
- `GET /devices/add`
|
||||
- `GET /me/security`
|
||||
- `GET /me/about`
|
||||
- `GET /me/storage`
|
||||
- `GET /me/ai-accounts`
|
||||
- `GET /me/ops`
|
||||
- `GET /me/ops/audit`
|
||||
@@ -389,6 +392,69 @@
|
||||
- 当前目标既可以是单线程会话,也可以是群聊、`主 Agent` 或 `审计对话`
|
||||
- 非开发任务下如命中线程沟通限制,接口会预留 `approvalRequired / approvalReason` 返回
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/attachments`
|
||||
|
||||
- 用途:上传图片 / 视频 / 文件,并在当前会话写入附件消息
|
||||
- 输入:
|
||||
- `file`
|
||||
- `sourceType`: `image | video | file`
|
||||
- 当前行为:
|
||||
- 默认使用当前登录用户的附件存储配置,未配置时走 `server_file`
|
||||
- 用户切到 `oss + aliyun_oss` 后,会上传到阿里 OSS 私有桶,并在附件里固化 `storageSnapshot`
|
||||
- 图片 / PDF / 文本默认会生成 `queued_auto` 附件分析任务
|
||||
- 视频 / Office / 大文件默认标记为 `ready_manual`
|
||||
- 返回:
|
||||
- `message`
|
||||
- `attachment`
|
||||
- `analysisTask`
|
||||
- `downloadUrl`
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/attachments/[attachmentId]/analyze`
|
||||
|
||||
- 用途:手动触发某个附件的主 Agent 分析
|
||||
- 当前行为:
|
||||
- 仅允许对 `ready_manual` 或 `failed` 状态的附件重新发起
|
||||
- 返回新的 `attachment_analysis` 任务摘要
|
||||
|
||||
#### `GET /api/v1/attachments/[attachmentId]/download`
|
||||
|
||||
- 用途:受保护下载或预览附件
|
||||
- 当前保护:
|
||||
- 默认要求有效 `boss_session`
|
||||
- 对主 Agent 附件分析任务,支持 `taskId + token` 的受控下载
|
||||
- 当前行为:
|
||||
- `server_file` 会直接流式返回文件
|
||||
- `aliyun_oss` 会按附件上传时固化的 `storageSnapshot` 生成临时签名 URL 并跳转
|
||||
|
||||
#### `GET /api/v1/storage/config`
|
||||
|
||||
- 用途:读取当前登录用户的附件存储配置
|
||||
- 返回:
|
||||
- `mode`: `server_file | oss`
|
||||
- `ossProvider`
|
||||
- 已脱敏的 OSS 配置摘要
|
||||
|
||||
#### `PATCH /api/v1/storage/config`
|
||||
|
||||
- 用途:更新当前登录用户的附件存储模式或 OSS 草稿配置
|
||||
- 当前行为:
|
||||
- 默认模式为 `server_file`
|
||||
- 当前 OSS provider 仅支持 `aliyun_oss`
|
||||
|
||||
#### `POST /api/v1/storage/config/validate`
|
||||
|
||||
- 用途:校验并保存当前登录用户的阿里 OSS 配置
|
||||
- 最小配置字段:
|
||||
- `accessKeyId`
|
||||
- `accessKeySecret`
|
||||
- `bucket`
|
||||
- `endpoint`
|
||||
- `region`
|
||||
- `prefix`(可选)
|
||||
- 当前行为:
|
||||
- 只支持阿里 OSS 私有桶 + 签名 URL
|
||||
- 成功后会回写已脱敏配置和验证时间
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/goals`
|
||||
|
||||
- 用途:新增项目目标
|
||||
@@ -601,6 +667,7 @@
|
||||
- `authSessions`
|
||||
- `aiAccounts`
|
||||
- `aiAccountSwitchHistory`
|
||||
- `userAttachmentStorageConfigs`
|
||||
- `threadContextSnapshots`
|
||||
- `threadHandoffPackages`
|
||||
- `threadContextAlerts`
|
||||
@@ -623,5 +690,6 @@
|
||||
|
||||
- 正式数据库
|
||||
- 正式鉴权中间件
|
||||
- 图片 / 视频真实文件上传和对象存储
|
||||
- 多家对象存储适配(当前只有服务器文件存储和阿里 OSS)
|
||||
- 完整的附件详情页与富预览器
|
||||
- 完整的多端用户会话系统与刷新令牌体系
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Boss 当前运行与部署状态
|
||||
|
||||
更新时间:`2026-03-28`
|
||||
更新时间:`2026-03-29`
|
||||
|
||||
## 1. 本地状态
|
||||
|
||||
@@ -114,7 +114,7 @@ cd /Users/kris/code/boss
|
||||
- 当前已生成 Android debug APK:`android/app/build/outputs/apk/debug/app-debug.apk`
|
||||
- 当前已生成 Android signed release APK:`android/app/build/outputs/apk/release/app-release.apk`
|
||||
- 当前 release 构建还会额外生成带版本号的 APK:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
|
||||
- 当前最新 release 构建版本:`2.4.0`(`versionCode=12`)
|
||||
- 当前最新 release 构建版本:`2.5.0`(`versionCode=13`)
|
||||
- 当前 release keystore 位于本机 `android/keystores/boss-release.keystore`,签名参数位于 `android/signing/release-signing.properties`
|
||||
- `2.0.1` 已在本机连接的华为真机上复核通过,修复了 `Theme.SplashScreen` 导致的 `AppCompatActivity` 启动闪退
|
||||
- `2.1.0` 已把 Web 一级页和主要二级页全部补成原生活动页:`MainActivity / ProjectDetailActivity / ProjectGoalsActivity / ProjectVersionsActivity / ProjectForwardActivity / ThreadDetailActivity / DeviceDetailActivity / DeviceEnrollmentActivity / SkillInventoryActivity / SecurityActivity / SettingsActivity / AiAccountsActivity / OpsCenterActivity / AboutActivity`
|
||||
@@ -124,6 +124,9 @@ cd /Users/kris/code/boss
|
||||
- `2.2.1` 已继续补齐原生交互细节:聊天页会即时显示本地“发送中”气泡,并且只在用户接近底部或本次发送主动触发时自动滚到底;关于页会显示 OTA 下载进度 / 重试 / 安装授权提示,离开后再回来仍会恢复本地下载状态;根 tab 会记住最近一次用户停留页,并把一级页返回逻辑收成“先回会话 tab,再按一次返回进入后台”
|
||||
- `2.3.0` 已把原生会话模型切到“线程 = 聊天窗口”:补上文件夹名副信息、后台活跃数量动态图标、微信式会话信息页、线程改名、独立群聊创建、群资料页,以及 `主 Agent / 审计对话` 普通置顶会话化
|
||||
- `2.4.0` 已把原生消息转发切到微信式链路:单条消息支持长按直接转发,多选消息支持合并转发成聊天记录卡片,统一使用原生会话选择页替换旧的备注转发页
|
||||
- `2.5.0` 已补齐聊天附件主链:原生聊天框左侧 `+` 已改成底部抽屉,支持图片 / 视频 / 文件发送;图片 / PDF / 文本会自动排队给主 Agent 分析,视频 / Office / 大文件改成手动触发
|
||||
- `2.5.0` 已上线 `我的 > 附件与存储`:默认使用服务器文件存储,用户可切到阿里 OSS 私有桶并填写最小配置;下载链会使用附件上传时固化的 OSS 快照,避免后续改配置后旧附件失效
|
||||
- 当前附件分析任务已带受控 `task token` 下载链接和文本摘录:本地开发环境会跟随请求 origin 生成链接,生产环境默认走 `https://boss.hyzq.net`
|
||||
|
||||
## 2. 服务器状态
|
||||
|
||||
@@ -207,7 +210,7 @@ cd /Users/kris/code/boss
|
||||
- API 容灾当前由用户在 APP 的 `我的 > AI 账号` 页面自行配置 `OpenAI API` 账号,不再依赖服务器预置 Key
|
||||
- 原生 Android 的二级深层页虽然仍保留 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等能力,但它们已经退出主 UI 正面;后续如再加入口,需继续遵守“一级微信式,复杂能力下沉”的规则
|
||||
- Android 本地 Gradle 验证当前必须串行执行;如果并发跑 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug`,会导致中间产物互踩并出现假失败
|
||||
- 图片 / 视频真实文件上传仍未接对象存储
|
||||
- 聊天附件当前已经支持真实上传、消息落账本、受保护下载和原生打开;默认后端为服务器文件存储,可按用户切到阿里 OSS 私有桶
|
||||
- 认证虽然已有最小会话 Cookie,但还没有刷新令牌、跨端会话治理、CSRF 防护和更细的风控策略
|
||||
- 邮件对外正式投递仍缺少 DNS / 信誉相关的最终收口,例如 SPF、DKIM、DMARC、MX 与退信策略
|
||||
- 外部真实邮箱的 end-to-end 收件链路还没有在生产账号上完成最终验收
|
||||
|
||||
@@ -0,0 +1,995 @@
|
||||
# Boss 聊天附件、双存储与 AI 处理 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为 Boss 原生聊天链路补齐图片/视频/文件发送、默认服务器文件存储、可选阿里 OSS、统一附件消息模型、主 Agent 附件分析,以及 Web 端 `我的 > 附件与存储` 简化配置页。
|
||||
|
||||
**Architecture:** 保持现有 `boss-state.json + Next API + BossApiClient + 原生 Android` 路线,不引入新的数据库或消息队列。服务端新增统一附件存储抽象层,默认走服务器文件存储,可按用户切到阿里 OSS;原生端只与统一上传/下载接口交互,AI 分析统一走主 Agent 任务链。
|
||||
|
||||
**Tech Stack:** Next.js App Router、TypeScript、Node.js `fs`、阿里云 OSS Node SDK、原生 Android `AppCompatActivity + ActivityResultContracts + HttpURLConnection`、现有 `boss-master-agent` 队列、文件型持久化 `data/boss-state.json`。
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### 需要新增的主要文件
|
||||
|
||||
- `src/lib/boss-attachments.ts`
|
||||
- 统一附件类型推断、大小阈值判断、文件名清洗、下载响应帮助函数。
|
||||
- `src/lib/boss-storage.ts`
|
||||
- 定义 `AttachmentStorageProvider` 接口、按用户配置选择 `server_file / aliyun_oss`。
|
||||
- `src/lib/boss-storage-server-file.ts`
|
||||
- 服务器本地文件存储实现。
|
||||
- `src/lib/boss-storage-aliyun-oss.ts`
|
||||
- 阿里 OSS 上传、签名 URL、配置校验。
|
||||
- `src/app/api/v1/storage/config/route.ts`
|
||||
- 当前登录用户的附件与存储配置读取/更新。
|
||||
- `src/app/api/v1/storage/config/validate/route.ts`
|
||||
- 阿里 OSS 配置有效性验证。
|
||||
- `src/app/api/v1/projects/[projectId]/attachments/route.ts`
|
||||
- 统一附件上传入口。
|
||||
- `src/app/api/v1/attachments/[attachmentId]/download/route.ts`
|
||||
- 统一预览/下载入口。
|
||||
- `src/app/api/v1/projects/[projectId]/attachments/[attachmentId]/analyze/route.ts`
|
||||
- 手动触发附件分析。
|
||||
- `src/app/me/storage/page.tsx`
|
||||
- Web 端 `我的 > 附件与存储` 页面。
|
||||
- `android/app/src/test/java/com/hyzq/boss/AttachmentComposerStateTest.java`
|
||||
- 原生附件入口与确认状态单测。
|
||||
- `android/app/src/test/java/com/hyzq/boss/BossApiClientAttachmentTest.java`
|
||||
- 原生上传/下载/手动分析 API 客户端测试。
|
||||
|
||||
### 需要修改的主要文件
|
||||
|
||||
- `src/lib/boss-data.ts`
|
||||
- 扩展用户级存储配置、附件消息模型、分析状态、主 Agent 附件任务数据。
|
||||
- `src/lib/boss-master-agent.ts`
|
||||
- 补 `attachment_analysis` 任务类型、附件摘要结果回写。
|
||||
- `src/app/api/v1/projects/[projectId]/messages/route.ts`
|
||||
- 保持文本消息主链,但允许附件分析结果回写卡片。
|
||||
- `src/lib/boss-projections.ts`
|
||||
- 把附件消息和分析状态投影给 Web。
|
||||
- `src/components/app-ui.tsx`
|
||||
- Web 会话页补附件消息展示和 `我的 > 附件与存储` 入口。
|
||||
- `src/app/me/page.tsx`
|
||||
- 增加 `附件与存储` 菜单入口。
|
||||
- `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
- 增加附件上传、下载、手动分析调用。
|
||||
- `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- 增加 `+` 按钮底部抽屉、图片/视频确认、文件发送、附件消息渲染与动作。
|
||||
- `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- 增加附件气泡 / 卡片 UI 构造。
|
||||
- `android/app/src/main/res/layout/activity_project_chat.xml`
|
||||
- 在输入区增加 `+` 和附件抽屉入口挂点。
|
||||
- `android/app/src/main/AndroidManifest.xml`
|
||||
- 如需文件打开/下载支持,补 `provider` 和系统选择权限声明。
|
||||
- `android/app/build.gradle`
|
||||
- 升版本号,必要时引入 Android 附件测试依赖。
|
||||
- `README.md`
|
||||
- `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- `docs/architecture/api_and_service_inventory_cn.md`
|
||||
- `docs/architecture/ai_handoff_index_cn.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 扩展服务端数据模型与用户级存储配置
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- Test: `src/lib/boss-data.ts`(先用 Node 侧最小读写回归;当前仓库没有单独 Vitest/Jest,先用 API 与状态读写验证)
|
||||
|
||||
- [ ] **Step 1: 先写 failing test 思路并用最小状态回归脚本表达预期**
|
||||
|
||||
在终端先确认当前状态模型还没有 `attachment` 和用户级存储配置。先准备一个最小断言脚本草稿,后面实现完成后执行:
|
||||
|
||||
```bash
|
||||
node - <<'EOF'
|
||||
const fs = require('node:fs');
|
||||
const state = JSON.parse(fs.readFileSync('data/boss-state.json', 'utf8'));
|
||||
if (!state.userAttachmentStorageConfigs) {
|
||||
console.error('MISSING:userAttachmentStorageConfigs');
|
||||
process.exit(1);
|
||||
}
|
||||
const accountConfig = state.userAttachmentStorageConfigs.find((item) => item.account === '17600003315');
|
||||
if (!accountConfig) {
|
||||
console.error('MISSING:account storage config');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('OK');
|
||||
EOF
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行脚本,确认当前会失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node - <<'EOF'
|
||||
const fs = require('node:fs');
|
||||
const state = JSON.parse(fs.readFileSync('data/boss-state.json', 'utf8'));
|
||||
if (!state.userAttachmentStorageConfigs) {
|
||||
console.error('MISSING:userAttachmentStorageConfigs');
|
||||
process.exit(1);
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
Expected: FAIL,提示 `MISSING:userAttachmentStorageConfigs`。
|
||||
|
||||
- [ ] **Step 3: 在 `boss-data.ts` 增加附件类型和用户级存储配置模型**
|
||||
|
||||
把 `MessageKind`、`Message`、用户配置和分析状态补成如下结构:
|
||||
|
||||
```ts
|
||||
export type MessageKind =
|
||||
| "text"
|
||||
| "voice_intent"
|
||||
| "image_intent"
|
||||
| "video_intent"
|
||||
| "forward_notice"
|
||||
| "forward_single"
|
||||
| "forward_bundle"
|
||||
| "attachment"
|
||||
| "analysis_card";
|
||||
|
||||
export type AttachmentKind = "image" | "video" | "pdf" | "text" | "office" | "binary";
|
||||
export type AttachmentStorageBackend = "server_file" | "aliyun_oss";
|
||||
export type AttachmentAnalysisState =
|
||||
| "not_applicable"
|
||||
| "queued_auto"
|
||||
| "ready_manual"
|
||||
| "processing"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
export interface MessageAttachment {
|
||||
attachmentId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSizeBytes: number;
|
||||
attachmentKind: AttachmentKind;
|
||||
storageBackend: AttachmentStorageBackend;
|
||||
storagePath: string;
|
||||
previewAvailable: boolean;
|
||||
uploadedAt: string;
|
||||
uploadedBy: string;
|
||||
analysisState: AttachmentAnalysisState;
|
||||
analysisSummary?: string;
|
||||
analysisCardId?: string;
|
||||
}
|
||||
|
||||
export interface UserAttachmentStorageConfig {
|
||||
account: string;
|
||||
mode: "server_file" | "oss";
|
||||
ossProvider?: "aliyun_oss";
|
||||
aliyunOss?: {
|
||||
enabled: boolean;
|
||||
accessKeyId: string;
|
||||
accessKeySecretEncrypted: string;
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
region: string;
|
||||
prefix?: string;
|
||||
};
|
||||
updatedAt: string;
|
||||
validatedAt?: string;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 给默认状态补上 `server_file` 配置和读写 helper**
|
||||
|
||||
在默认状态初始化处加入:
|
||||
|
||||
```ts
|
||||
userAttachmentStorageConfigs: [
|
||||
{
|
||||
account: "17600003315",
|
||||
mode: "server_file",
|
||||
updatedAt: nowIso(),
|
||||
},
|
||||
],
|
||||
```
|
||||
|
||||
并增加 helper:
|
||||
|
||||
```ts
|
||||
export async function getAttachmentStorageConfig(account: string) {
|
||||
const state = await readState();
|
||||
return (
|
||||
state.userAttachmentStorageConfigs.find((item) => item.account === account) ?? {
|
||||
account,
|
||||
mode: "server_file" as const,
|
||||
updatedAt: nowIso(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function upsertAttachmentStorageConfig(config: UserAttachmentStorageConfig) {
|
||||
return mutateState((state) => {
|
||||
const index = state.userAttachmentStorageConfigs.findIndex((item) => item.account === config.account);
|
||||
if (index >= 0) {
|
||||
state.userAttachmentStorageConfigs[index] = config;
|
||||
} else {
|
||||
state.userAttachmentStorageConfigs.push(config);
|
||||
}
|
||||
return config;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 运行回归脚本,确认模型已经存在**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node - <<'EOF'
|
||||
const fs = require('node:fs');
|
||||
const state = JSON.parse(fs.readFileSync('data/boss-state.json', 'utf8'));
|
||||
console.log(Array.isArray(state.userAttachmentStorageConfigs) ? 'OK' : 'FAIL');
|
||||
EOF
|
||||
```
|
||||
|
||||
Expected: 输出 `OK`。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/boss-data.ts
|
||||
git commit -m "feat: add attachment storage config model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 先把统一附件工具和服务器文件存储跑通
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/boss-attachments.ts`
|
||||
- Create: `src/lib/boss-storage.ts`
|
||||
- Create: `src/lib/boss-storage-server-file.ts`
|
||||
- Create: `src/app/api/v1/projects/[projectId]/attachments/route.ts`
|
||||
- Create: `src/app/api/v1/attachments/[attachmentId]/download/route.ts`
|
||||
|
||||
- [ ] **Step 1: 先写 failing API 验证脚本,确认上传接口还不存在**
|
||||
|
||||
```bash
|
||||
tmpdir=$(mktemp -d)
|
||||
cookie="$tmpdir/cookies.txt"
|
||||
curl -sS -c "$cookie" -b "$cookie" -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:3000/api/auth/login >/dev/null
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' -c "$cookie" -b "$cookie" -F "file=@README.md" http://127.0.0.1:3000/api/v1/projects/boss-console/attachments
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行脚本,确认当前是 404 或未实现失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tmpdir=$(mktemp -d)
|
||||
cookie="$tmpdir/cookies.txt"
|
||||
curl -sS -c "$cookie" -b "$cookie" -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:3000/api/auth/login >/dev/null
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' -c "$cookie" -b "$cookie" -F "file=@README.md" http://127.0.0.1:3000/api/v1/projects/boss-console/attachments
|
||||
```
|
||||
|
||||
Expected: 不是 `200`,说明接口尚未实现。
|
||||
|
||||
- [ ] **Step 3: 在 `boss-attachments.ts` 实现类型归类和自动/手动分析判定**
|
||||
|
||||
```ts
|
||||
export function detectAttachmentKind(fileName: string, mimeType: string): AttachmentKind {
|
||||
if (mimeType.startsWith("image/")) return "image";
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
if (mimeType === "application/pdf") return "pdf";
|
||||
if (mimeType.startsWith("text/")) return "text";
|
||||
if (
|
||||
mimeType.includes("officedocument") ||
|
||||
mimeType.includes("msword") ||
|
||||
mimeType.includes("spreadsheet") ||
|
||||
mimeType.includes("presentation")
|
||||
) {
|
||||
return "office";
|
||||
}
|
||||
return "binary";
|
||||
}
|
||||
|
||||
export function resolveAttachmentAnalysisState(kind: AttachmentKind, fileSizeBytes: number): AttachmentAnalysisState {
|
||||
const isLarge = fileSizeBytes > 20 * 1024 * 1024;
|
||||
if (isLarge) return "ready_manual";
|
||||
if (kind === "image" || kind === "pdf" || kind === "text") return "queued_auto";
|
||||
return "ready_manual";
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 在 `boss-storage-server-file.ts` 实现本地文件上传与下载定位**
|
||||
|
||||
```ts
|
||||
export async function storeServerFileAttachment(params: {
|
||||
account: string;
|
||||
messageId: string;
|
||||
fileName: string;
|
||||
buffer: Buffer;
|
||||
}) {
|
||||
const now = new Date();
|
||||
const relativePath = path.join(
|
||||
"data",
|
||||
"uploads",
|
||||
params.account,
|
||||
String(now.getUTCFullYear()),
|
||||
String(now.getUTCMonth() + 1).padStart(2, "0"),
|
||||
`${params.messageId}-${sanitizeFileName(params.fileName)}`,
|
||||
);
|
||||
const absolutePath = path.join(resolveRuntimeRoot(), relativePath);
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, params.buffer);
|
||||
return {
|
||||
storageBackend: "server_file" as const,
|
||||
storagePath: relativePath,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 在上传 route 里先实现 `server_file` 主链**
|
||||
|
||||
核心逻辑最小实现:
|
||||
|
||||
```ts
|
||||
const form = await request.formData();
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ ok: false, message: "FILE_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
const bytes = Buffer.from(await file.arrayBuffer());
|
||||
const attachmentId = randomToken("att");
|
||||
const messageId = randomToken("msg");
|
||||
const attachmentKind = detectAttachmentKind(file.name, file.type || "application/octet-stream");
|
||||
const analysisState = resolveAttachmentAnalysisState(attachmentKind, bytes.byteLength);
|
||||
const stored = await provider.storeAttachment(...);
|
||||
const message = await appendAttachmentMessage(...);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 在下载 route 里实现 `server_file` 流式返回**
|
||||
|
||||
```ts
|
||||
if (attachment.storageBackend === "server_file") {
|
||||
const absolutePath = path.join(resolveRuntimeRoot(), attachment.storagePath);
|
||||
const stream = createReadStream(absolutePath);
|
||||
return new NextResponse(Readable.toWeb(stream) as ReadableStream, {
|
||||
headers: {
|
||||
"Content-Type": attachment.mimeType,
|
||||
"Content-Disposition": `inline; filename="${attachment.fileName}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 启动本地服务并验证上传/下载通过**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
再执行:
|
||||
|
||||
```bash
|
||||
tmpdir=$(mktemp -d)
|
||||
cookie="$tmpdir/cookies.txt"
|
||||
curl -sS -c "$cookie" -b "$cookie" -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:3000/api/auth/login >/dev/null
|
||||
curl -sS -c "$cookie" -b "$cookie" -F "file=@README.md;type=text/plain" http://127.0.0.1:3000/api/v1/projects/boss-console/attachments
|
||||
```
|
||||
|
||||
Expected: 返回 `ok:true` 且消息 `kind=attachment`。
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/boss-attachments.ts src/lib/boss-storage.ts src/lib/boss-storage-server-file.ts src/app/api/v1/projects/[projectId]/attachments/route.ts src/app/api/v1/attachments/[attachmentId]/download/route.ts
|
||||
git commit -m "feat: add server file attachment pipeline"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 接入阿里 OSS 私有桶与配置校验
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/boss-storage-aliyun-oss.ts`
|
||||
- Create: `src/app/api/v1/storage/config/route.ts`
|
||||
- Create: `src/app/api/v1/storage/config/validate/route.ts`
|
||||
- Modify: `src/lib/boss-storage.ts`
|
||||
- Modify: `package.json`
|
||||
|
||||
- [ ] **Step 1: 先写 failing config 路由验证**
|
||||
|
||||
```bash
|
||||
tmpdir=$(mktemp -d)
|
||||
cookie="$tmpdir/cookies.txt"
|
||||
curl -sS -c "$cookie" -b "$cookie" -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:3000/api/auth/login >/dev/null
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' -c "$cookie" -b "$cookie" http://127.0.0.1:3000/api/v1/storage/config
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行,确认当前是 404**
|
||||
|
||||
Run 同上。
|
||||
Expected: `404`。
|
||||
|
||||
- [ ] **Step 3: 安装阿里 OSS SDK 并实现 provider**
|
||||
|
||||
```bash
|
||||
npm install ali-oss
|
||||
```
|
||||
|
||||
在 `boss-storage-aliyun-oss.ts` 最小实现:
|
||||
|
||||
```ts
|
||||
import OSS from "ali-oss";
|
||||
|
||||
export function createAliyunOssClient(config: UserAttachmentStorageConfig["aliyunOss"]) {
|
||||
if (!config?.enabled) throw new Error("ALIYUN_OSS_NOT_ENABLED");
|
||||
return new OSS({
|
||||
accessKeyId: config.accessKeyId,
|
||||
accessKeySecret: decryptStorageSecret(config.accessKeySecretEncrypted),
|
||||
bucket: config.bucket,
|
||||
endpoint: config.endpoint,
|
||||
region: config.region,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 实现 `GET/PATCH /api/v1/storage/config`**
|
||||
|
||||
要求:
|
||||
|
||||
- `GET` 返回当前用户配置
|
||||
- `PATCH` 接受 `mode`、`ossProvider`、`aliyunOss`
|
||||
- `PATCH` 时对 `AccessKey Secret` 做加密,不明文落库
|
||||
|
||||
最小返回结构:
|
||||
|
||||
```ts
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
config: sanitizeStorageConfig(savedConfig),
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 实现 `POST /api/v1/storage/config/validate`**
|
||||
|
||||
使用 OSS SDK 执行最小探针:
|
||||
|
||||
```ts
|
||||
await client.getBucketInfo();
|
||||
return NextResponse.json({ ok: true, provider: "aliyun_oss" });
|
||||
```
|
||||
|
||||
失败时返回:
|
||||
|
||||
```ts
|
||||
return NextResponse.json({ ok: false, message: normalizeStorageError(error) }, { status: 400 });
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 在 `boss-storage.ts` 中按用户配置分流到 `server_file / aliyun_oss`**
|
||||
|
||||
```ts
|
||||
export async function resolveAttachmentStorageProvider(account: string): Promise<AttachmentStorageProvider> {
|
||||
const config = await getAttachmentStorageConfig(account);
|
||||
if (config.mode === "oss" && config.ossProvider === "aliyun_oss") {
|
||||
return createAliyunOssStorageProvider(config);
|
||||
}
|
||||
return createServerFileStorageProvider();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 运行接口验证**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tmpdir=$(mktemp -d)
|
||||
cookie="$tmpdir/cookies.txt"
|
||||
curl -sS -c "$cookie" -b "$cookie" -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:3000/api/auth/login >/dev/null
|
||||
curl -sS -c "$cookie" -b "$cookie" http://127.0.0.1:3000/api/v1/storage/config
|
||||
```
|
||||
|
||||
Expected: 返回默认 `mode=server_file`。
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json package-lock.json src/lib/boss-storage.ts src/lib/boss-storage-aliyun-oss.ts src/app/api/v1/storage/config/route.ts src/app/api/v1/storage/config/validate/route.ts
|
||||
git commit -m "feat: add aliyun oss storage config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 打通附件消息创建、下载元数据和主 Agent 分析任务
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- Modify: `src/lib/boss-master-agent.ts`
|
||||
- Create: `src/app/api/v1/projects/[projectId]/attachments/[attachmentId]/analyze/route.ts`
|
||||
- Modify: `src/app/api/v1/projects/[projectId]/attachments/route.ts`
|
||||
|
||||
- [ ] **Step 1: 先写 failing API 行为验证,确认分析接口未实现**
|
||||
|
||||
```bash
|
||||
tmpdir=$(mktemp -d)
|
||||
cookie="$tmpdir/cookies.txt"
|
||||
curl -sS -c "$cookie" -b "$cookie" -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:3000/api/auth/login >/dev/null
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' -c "$cookie" -b "$cookie" -X POST http://127.0.0.1:3000/api/v1/projects/boss-console/attachments/att-missing/analyze
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行,确认不是成功状态**
|
||||
|
||||
Expected: 404/400。
|
||||
|
||||
- [ ] **Step 3: 在 `boss-data.ts` 中增加附件消息 helper**
|
||||
|
||||
新增:
|
||||
|
||||
```ts
|
||||
export async function appendAttachmentMessage(payload: {
|
||||
projectId: string;
|
||||
senderLabel: string;
|
||||
body: string;
|
||||
attachment: MessageAttachment;
|
||||
}) {
|
||||
return appendProjectMessage({
|
||||
projectId: payload.projectId,
|
||||
senderLabel: payload.senderLabel,
|
||||
body: payload.body,
|
||||
kind: "attachment",
|
||||
attachment: payload.attachment,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
并把 `appendProjectMessage` 扩展为支持:
|
||||
|
||||
```ts
|
||||
attachment?: MessageAttachment;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 在 `boss-master-agent.ts` 增加 `attachment_analysis` 任务类型**
|
||||
|
||||
最小接口:
|
||||
|
||||
```ts
|
||||
export async function queueAttachmentAnalysisTask(params: AttachmentAnalysisTaskPayload) {
|
||||
return queueMasterAgentTask({
|
||||
taskType: "attachment_analysis",
|
||||
projectId: params.projectId,
|
||||
requestText: `请分析附件:${params.fileName}`,
|
||||
payload: params,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
并在任务完成回写时,新增:
|
||||
|
||||
```ts
|
||||
await appendProjectMessage({
|
||||
projectId: task.projectId,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: shortSummary,
|
||||
kind: "text",
|
||||
});
|
||||
|
||||
await appendProjectMessage({
|
||||
projectId: task.projectId,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: cardTitle,
|
||||
kind: "analysis_card",
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 在上传 route 中自动创建分析任务**
|
||||
|
||||
规则:
|
||||
|
||||
```ts
|
||||
if (attachment.analysisState === "queued_auto") {
|
||||
await queueAttachmentAnalysisTask(...);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 实现手动分析接口**
|
||||
|
||||
```ts
|
||||
export async function POST(...) {
|
||||
const attachment = await findProjectAttachment(projectId, attachmentId);
|
||||
if (!attachment) return NextResponse.json({ ok: false, message: "ATTACHMENT_NOT_FOUND" }, { status: 404 });
|
||||
if (attachment.analysisState !== "ready_manual" && attachment.analysisState !== "failed") {
|
||||
return NextResponse.json({ ok: false, message: "ATTACHMENT_ANALYZE_NOT_ALLOWED" }, { status: 400 });
|
||||
}
|
||||
const task = await queueAttachmentAnalysisTask(...);
|
||||
return NextResponse.json({ ok: true, taskId: task.taskId });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 验证自动/手动状态判定**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
tmpdir=$(mktemp -d)
|
||||
cookie="$tmpdir/cookies.txt"
|
||||
curl -sS -c "$cookie" -b "$cookie" -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:3000/api/auth/login >/dev/null
|
||||
curl -sS -c "$cookie" -b "$cookie" -F "file=@README.md;type=text/plain" http://127.0.0.1:3000/api/v1/projects/boss-console/attachments
|
||||
```
|
||||
|
||||
Expected: 返回的附件消息 `analysisState=queued_auto`。
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/boss-data.ts src/lib/boss-master-agent.ts src/app/api/v1/projects/[projectId]/attachments/route.ts src/app/api/v1/projects/[projectId]/attachments/[attachmentId]/analyze/route.ts
|
||||
git commit -m "feat: add attachment analysis task flow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Web 端补 `我的 > 附件与存储`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/me/storage/page.tsx`
|
||||
- Modify: `src/app/me/page.tsx`
|
||||
- Modify: `src/components/app-ui.tsx`
|
||||
- Modify: `src/app/api/v1/settings` only if existing menu projection needs an extra field (otherwise keep scope local)
|
||||
|
||||
- [ ] **Step 1: 先写 failing 路由访问验证**
|
||||
|
||||
```bash
|
||||
tmpdir=$(mktemp -d)
|
||||
cookie="$tmpdir/cookies.txt"
|
||||
curl -sS -c "$cookie" -b "$cookie" -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:3000/api/auth/login >/dev/null
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' -c "$cookie" -b "$cookie" http://127.0.0.1:3000/me/storage
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行,确认当前不是 200**
|
||||
|
||||
Expected: `404`。
|
||||
|
||||
- [ ] **Step 3: 创建页面并只做两层交互**
|
||||
|
||||
`page.tsx` 结构最小如下:
|
||||
|
||||
```tsx
|
||||
export default async function StoragePage() {
|
||||
const config = await getAttachmentStorageConfigForSession();
|
||||
return (
|
||||
<main className="space-y-4">
|
||||
<section>
|
||||
<h1>附件与存储</h1>
|
||||
<p>默认使用服务器文件存储,也可以切到阿里 OSS。</p>
|
||||
</section>
|
||||
<StorageModeCard config={config} />
|
||||
{config.mode === "oss" ? <AliyunOssForm config={config} /> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 在 `我的` 根页加菜单入口**
|
||||
|
||||
```tsx
|
||||
<Link href="/me/storage">附件与存储</Link>
|
||||
```
|
||||
|
||||
保持它与 `账号与安全 / AI 账号 / 技能 / 关于` 同级,继续微信式简单列表,不引入大面板。
|
||||
|
||||
- [ ] **Step 5: 用最小表单接 `GET/PATCH/validate`**
|
||||
|
||||
至少支持:
|
||||
|
||||
- 选择 `服务器文件存储 / OSS`
|
||||
- 选 `OSS` 后只显示 `阿里 OSS`
|
||||
- 填 `AK / SK / Bucket / Endpoint / Region / Prefix`
|
||||
- `测试并保存`
|
||||
- `切回服务器文件存储`
|
||||
|
||||
- [ ] **Step 6: 验证页面和配置链**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:3000/api/health
|
||||
curl -sS -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:3000/api/auth/login
|
||||
```
|
||||
|
||||
并在浏览器打开:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:3000/me/storage
|
||||
```
|
||||
|
||||
Expected: 页面可访问,能显示默认 `server_file`。
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/me/storage/page.tsx src/app/me/page.tsx src/components/app-ui.tsx
|
||||
git commit -m "feat: add attachment storage settings page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 原生 Android 接入附件选择、上传和消息渲染
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- Modify: `android/app/src/main/res/layout/activity_project_chat.xml`
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/AttachmentComposerStateTest.java`
|
||||
- Create: `android/app/src/test/java/com/hyzq/boss/BossApiClientAttachmentTest.java`
|
||||
|
||||
- [ ] **Step 1: 先写 failing 原生状态测试**
|
||||
|
||||
在 `AttachmentComposerStateTest.java` 先写:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void imageAndVideoRequireConfirmationButFileDoesNot() {
|
||||
assertTrue(ProjectChatUiState.requiresAttachmentConfirmation("image"));
|
||||
assertTrue(ProjectChatUiState.requiresAttachmentConfirmation("video"));
|
||||
assertFalse(ProjectChatUiState.requiresAttachmentConfirmation("file"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试,确认当前失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew testDebugUnitTest --tests com.hyzq.boss.AttachmentComposerStateTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL,提示 helper 未实现。
|
||||
|
||||
- [ ] **Step 3: 在 `ProjectChatUiState` / `ProjectDetailActivity` 实现附件入口状态**
|
||||
|
||||
最小 helper:
|
||||
|
||||
```java
|
||||
static boolean requiresAttachmentConfirmation(String sourceType) {
|
||||
return "image".equals(sourceType) || "video".equals(sourceType);
|
||||
}
|
||||
```
|
||||
|
||||
并在 `ProjectDetailActivity` 中增加:
|
||||
|
||||
- 左侧 `+` 按钮
|
||||
- 底部抽屉容器
|
||||
- 三个入口:图片 / 视频 / 文件
|
||||
- `ActivityResultLauncher<String>` 或 `OpenDocument` 注册器
|
||||
|
||||
- [ ] **Step 4: 在 `BossApiClient` 增加 multipart 上传**
|
||||
|
||||
实现最小接口:
|
||||
|
||||
```java
|
||||
public ApiResponse uploadAttachment(String projectId, String fileName, String mimeType, byte[] bytes, String sourceType) throws Exception
|
||||
```
|
||||
|
||||
以及:
|
||||
|
||||
```java
|
||||
public ApiResponse analyzeAttachment(String projectId, String attachmentId) throws Exception
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 在 `BossUi` 补附件消息卡片**
|
||||
|
||||
新增:
|
||||
|
||||
```java
|
||||
buildAttachmentMessageCard(...)
|
||||
buildAttachmentAnalysisStateChip(...)
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- 图片:缩略图占位 + 文件名 + 状态
|
||||
- 视频:封面占位 + 文件名 + 状态
|
||||
- 文件:文件图标 + 文件名 + 大小 + 状态
|
||||
|
||||
- [ ] **Step 6: 运行原生测试和 debug 构建**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew testDebugUnitTest --tests com.hyzq.boss.AttachmentComposerStateTest --tests com.hyzq.boss.BossApiClientAttachmentTest :app:compileDebugJavaWithJavac assembleDebug --no-daemon
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESSFUL。
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/hyzq/boss/BossApiClient.java android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java android/app/src/main/java/com/hyzq/boss/BossUi.java android/app/src/main/res/layout/activity_project_chat.xml android/app/src/test/java/com/hyzq/boss/AttachmentComposerStateTest.java android/app/src/test/java/com/hyzq/boss/BossApiClientAttachmentTest.java
|
||||
git commit -m "feat: add native chat attachment flow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 原生端补附件动作、分析状态和下载/预览
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- Modify: `android/app/src/main/AndroidManifest.xml`
|
||||
- Test: `android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java`
|
||||
|
||||
- [ ] **Step 1: 先写 failing UI 测试,验证附件状态动作**
|
||||
|
||||
在 `ProjectDetailActivityUiTest.java` 增补:
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void manualAnalysisAttachmentShowsActionChip() {
|
||||
// render 一个 analysisState=ready_manual 的 attachment message
|
||||
// 断言 UI 中出现“让 AI 分析”
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试,确认当前失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew testDebugUnitTest --tests com.hyzq.boss.ProjectDetailActivityUiTest --no-daemon
|
||||
```
|
||||
|
||||
Expected: FAIL。
|
||||
|
||||
- [ ] **Step 3: 在消息卡片中增加动作**
|
||||
|
||||
规则:
|
||||
|
||||
- `queued_auto`:显示“自动分析排队中”
|
||||
- `processing`:显示“AI 分析中”
|
||||
- `ready_manual`:显示按钮“让 AI 分析”
|
||||
- `completed`:显示摘要
|
||||
- `failed`:显示“重试分析”
|
||||
|
||||
- [ ] **Step 4: 接通下载/预览行为**
|
||||
|
||||
最小行为:
|
||||
|
||||
- 图片:打开下载 URL
|
||||
- 视频:打开下载 URL
|
||||
- 文件:打开下载 URL
|
||||
|
||||
先用系统浏览器或下载器打开,不在这轮强做自定义预览器。
|
||||
|
||||
- [ ] **Step 5: 运行 UI 测试与编译**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew testDebugUnitTest --tests com.hyzq.boss.ProjectDetailActivityUiTest :app:compileDebugJavaWithJavac assembleDebug --no-daemon
|
||||
```
|
||||
|
||||
Expected: BUILD SUCCESSFUL。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java android/app/src/main/java/com/hyzq/boss/BossUi.java android/app/src/main/AndroidManifest.xml android/app/src/test/java/com/hyzq/boss/ProjectDetailActivityUiTest.java
|
||||
git commit -m "feat: add attachment analysis states to native chat"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 文档、联调、发包、部署
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
|
||||
- Modify: `docs/architecture/ai_handoff_index_cn.md`
|
||||
- Modify: `android/app/build.gradle`
|
||||
- Modify: `public/downloads/*`(如果发 release)
|
||||
|
||||
- [ ] **Step 1: 本地完整验证**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
npm run lint
|
||||
npm run build
|
||||
curl -sS http://127.0.0.1:3000/api/health
|
||||
curl -sS http://127.0.0.1:4317/health
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- lint 通过
|
||||
- build 通过
|
||||
- 两个 health 都返回 `ok:true`
|
||||
|
||||
- [ ] **Step 2: Android 验证和打包**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss/android
|
||||
./gradlew testDebugUnitTest :app:compileDebugJavaWithJavac assembleDebug --no-daemon
|
||||
cd /Users/kris/code/boss
|
||||
JAVA_HOME=$(/usr/libexec/java_home) npm run apk:release
|
||||
JAVA_HOME=$(/usr/libexec/java_home) npm run aab:release
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Android 测试和构建成功
|
||||
- 产出新版本 APK / AAB
|
||||
|
||||
- [ ] **Step 3: 部署服务器并验证**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /Users/kris/code/boss
|
||||
./scripts/deploy-server.sh
|
||||
"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" exec "curl -sS http://127.0.0.1:3000/api/health"
|
||||
curl -sS https://boss.hyzq.net/api/health
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- 远端本机 health 正常
|
||||
- 公网 health 正常
|
||||
|
||||
- [ ] **Step 4: 文档同步**
|
||||
|
||||
把以下事实写回文档:
|
||||
|
||||
- 默认服务器文件存储已可用
|
||||
- `我的 > 附件与存储` 已上线
|
||||
- 阿里 OSS 私有桶已接入
|
||||
- 图片 / PDF / 文本自动分析
|
||||
- 视频 / Office / 大文件手动分析
|
||||
- 原生聊天附件入口和分析状态说明
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add README.md docs/architecture/current_runtime_and_deploy_status_cn.md docs/architecture/api_and_service_inventory_cn.md docs/architecture/ai_handoff_index_cn.md android/app/build.gradle public/downloads
|
||||
git commit -m "chore: publish attachment storage release"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
### Spec coverage
|
||||
|
||||
- 原生附件入口:Task 6、Task 7 覆盖
|
||||
- 默认服务器文件存储:Task 2 覆盖
|
||||
- 阿里 OSS:Task 3 覆盖
|
||||
- 用户级存储配置:Task 1、Task 3、Task 5 覆盖
|
||||
- 统一下载入口:Task 2 覆盖
|
||||
- 自动/手动分析:Task 4 覆盖
|
||||
- 主 Agent 分析回写:Task 4 覆盖
|
||||
- Web 简化配置页:Task 5 覆盖
|
||||
- 文档、部署、发包:Task 8 覆盖
|
||||
|
||||
### Placeholder scan
|
||||
|
||||
- 没有 `TODO / TBD / implement later / similar to task N`
|
||||
- 每个任务都给出了具体文件、命令和最小代码形状
|
||||
|
||||
### Type consistency
|
||||
|
||||
- `AttachmentStorageMode` 统一使用 `server_file | oss`
|
||||
- `OssProvider` 统一使用 `aliyun_oss`
|
||||
- `MessageKind` 统一新增 `attachment / analysis_card`
|
||||
- `AttachmentAnalysisState` 统一使用 `queued_auto / ready_manual / processing / completed / failed`
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
# Boss 聊天附件、双存储与 AI 处理设计
|
||||
|
||||
日期:`2026-03-29`
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前 `Boss` 原生 Android 客户端已经完成:
|
||||
|
||||
- 微信式 `会话 / 设备 / 我的` 一级交互
|
||||
- 线程 = 聊天窗口
|
||||
- 会话信息、独立群聊、微信式消息转发
|
||||
|
||||
但聊天主链仍缺少真实的附件协作能力:
|
||||
|
||||
- 聊天框还不能直接发送本机图片、视频、文件
|
||||
- 接收端还不能收到可预览/可下载的附件消息
|
||||
- 存储目前只有服务器本地文件路线,没有可切换的对象存储
|
||||
- AI 还不能基于聊天里的附件自动或手动分析内容
|
||||
|
||||
这次工作要把这四条链路一起打通:
|
||||
|
||||
1. 原生聊天框发送本机图片、视频、文件
|
||||
2. 默认服务器文件存储 + 可选阿里 OSS 私有桶
|
||||
3. Web 端做最简化的用户级存储配置
|
||||
4. 主 Agent 统一处理附件分析,并把结果回写到聊天中
|
||||
|
||||
## 2. 目标
|
||||
|
||||
本次设计完成后,系统应满足:
|
||||
|
||||
1. 原生 Android 聊天框左侧提供单个 `+` 附件入口。
|
||||
2. 点击 `+` 后通过底部抽屉选择 `图片 / 视频 / 文件`。
|
||||
3. 图片、视频在发送前需要预览确认;文件直接发送。
|
||||
4. 默认使用服务器文件存储。
|
||||
5. 用户可在 Web 端 `我的 > 附件与存储` 中切换到 `OSS`。
|
||||
6. `OSS` 当前只支持 `阿里 OSS`,并使用私有桶 + 签名 URL。
|
||||
7. 存储配置按当前登录用户保存,不做全局唯一配置。
|
||||
8. 接收端收到的附件消息不感知底层存储差异,都能统一预览/下载。
|
||||
9. 图片 / PDF / 文本默认自动交给主 Agent 处理。
|
||||
10. 视频 / Office / 大文件默认手动触发分析。
|
||||
11. “大文件”阈值固定为 `20 MB`。
|
||||
12. AI 处理结果以“简短回复 + 可继续查看的分析卡片”形式回到当前聊天。
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
本次明确不做:
|
||||
|
||||
1. 非阿里云的 OSS / S3 / COS 接入。
|
||||
2. 语音附件、录音、外部分享面板。
|
||||
3. 跨应用分享、系统分享菜单接入。
|
||||
4. 附件版本管理、回收站、批量迁移工具。
|
||||
5. 视频高级转码、视频在线播放 CDN 优化。
|
||||
6. Office / 视频的深度结构化解析引擎。
|
||||
7. 多租户复杂权限模型。
|
||||
|
||||
## 4. 已确认的产品决策
|
||||
|
||||
### 4.1 原生聊天入口
|
||||
|
||||
- 聊天输入框左侧保留单个 `+`
|
||||
- 点击后打开底部抽屉
|
||||
- 抽屉内固定展示:
|
||||
- `图片`
|
||||
- `视频`
|
||||
- `文件`
|
||||
|
||||
### 4.2 发送前确认
|
||||
|
||||
- 图片:选中后先进入发送确认态
|
||||
- 视频:选中后先进入发送确认态
|
||||
- 文件:直接进入发送,不额外确认
|
||||
|
||||
### 4.3 Web 配置入口
|
||||
|
||||
- 配置入口固定放在 `我的 > 附件与存储`
|
||||
- 不放在会话页,不藏在多层设置后面
|
||||
|
||||
### 4.4 OSS 最小配置项
|
||||
|
||||
阿里 OSS 最小配置字段固定为:
|
||||
|
||||
- `AccessKey ID`
|
||||
- `AccessKey Secret`
|
||||
- `Bucket`
|
||||
- `Endpoint`
|
||||
- `Region`
|
||||
- `目录前缀`(可选,默认 `boss/`)
|
||||
|
||||
### 4.5 AI 处理策略
|
||||
|
||||
- 图片 / PDF / 文本:默认自动处理
|
||||
- 视频 / Office / 大文件:默认手动触发
|
||||
- 大文件阈值:`20 MB`
|
||||
- 所有附件分析统一走主 Agent
|
||||
|
||||
### 4.6 AI 结果展示
|
||||
|
||||
分析结果返回当前聊天时,固定使用混合展示:
|
||||
|
||||
1. 一条简短的主 Agent 回复
|
||||
2. 一张分析结果卡片
|
||||
|
||||
## 5. 总体架构
|
||||
|
||||
### 5.1 发送链
|
||||
|
||||
发送链拆成两步:
|
||||
|
||||
1. 原生 APP 选择附件并上传
|
||||
2. 服务端创建附件消息
|
||||
|
||||
用户视角上,它们仍是一条连续动作。
|
||||
|
||||
上传成功后,服务端会在目标会话写入统一的“附件消息”,而不是单独写“上传请求消息”。
|
||||
|
||||
### 5.2 存储链
|
||||
|
||||
存储层统一抽象成 `AttachmentStorageProvider`,底层实现两种:
|
||||
|
||||
- `server_file`
|
||||
- `aliyun_oss`
|
||||
|
||||
所有附件都通过同一套元数据模型进入消息系统,聊天页和 AI 处理链不直接感知底层存储类型。
|
||||
|
||||
### 5.3 AI 处理链
|
||||
|
||||
AI 处理与上传解耦:
|
||||
|
||||
- 上传完成后先落附件消息
|
||||
- 再由服务端根据文件类型和大小决定是否自动排队给主 Agent
|
||||
- 不自动处理的附件,显示为“可分析”,由用户手动触发
|
||||
|
||||
### 5.4 接收链
|
||||
|
||||
接收端收到的消息固定是统一附件消息:
|
||||
|
||||
- 可看到文件类型、名称、大小、状态
|
||||
- 图片支持预览
|
||||
- 视频支持预览/下载
|
||||
- PDF / 文本 / Office / 其他文件支持下载
|
||||
- 可看到 AI 分析状态与结果
|
||||
|
||||
## 6. 存储设计
|
||||
|
||||
### 6.1 用户级配置模型
|
||||
|
||||
每个登录用户拥有独立的存储偏好:
|
||||
|
||||
```ts
|
||||
type AttachmentStorageMode = "server_file" | "oss";
|
||||
type OssProvider = "aliyun_oss";
|
||||
|
||||
interface UserAttachmentStorageConfig {
|
||||
account: string;
|
||||
mode: AttachmentStorageMode;
|
||||
ossProvider?: OssProvider;
|
||||
aliyunOss?: {
|
||||
enabled: boolean;
|
||||
accessKeyId: string;
|
||||
accessKeySecretEncrypted: string;
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
region: string;
|
||||
prefix?: string;
|
||||
};
|
||||
updatedAt: string;
|
||||
validatedAt?: string;
|
||||
}
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `mode=server_file` 时,不要求任何 OSS 字段
|
||||
- `mode=oss` 时,必须要求 `ossProvider=aliyun_oss`
|
||||
- `prefix` 默认值为 `boss/`
|
||||
|
||||
### 6.2 密钥存储策略
|
||||
|
||||
由于当前系统仍使用文件持久化,阿里 OSS 的 `AccessKey Secret` 不能明文直接写入 `boss-state.json`。
|
||||
|
||||
本次设计采用:
|
||||
|
||||
- `boss-state.json` 中只保存加密后的 `accessKeySecretEncrypted`
|
||||
- 服务器本地使用单独密钥对该字段做对称加密
|
||||
- 该本地密钥不进入仓库,不通过客户端下发
|
||||
|
||||
推荐实现方式:
|
||||
|
||||
- 优先读取环境变量,如 `BOSS_STORAGE_SECRET_KEY`
|
||||
- 如果不存在,则在服务器本地生成仅运行时可见的密钥文件并持久化到数据目录
|
||||
|
||||
### 6.3 默认服务器文件存储
|
||||
|
||||
默认存储后端为服务器本地文件。
|
||||
|
||||
建议目录结构:
|
||||
|
||||
```text
|
||||
data/uploads/<account>/<yyyy>/<mm>/<messageId>-<safeFileName>
|
||||
```
|
||||
|
||||
优势:
|
||||
|
||||
- 符合当前“极轻云 + 本地设备端”路线
|
||||
- 不引入额外云资源即可先跑通
|
||||
- 与现有 `boss-state.json` 路线一致
|
||||
|
||||
### 6.4 阿里 OSS 存储
|
||||
|
||||
阿里 OSS 固定采用:
|
||||
|
||||
- 私有桶
|
||||
- 临时签名 URL
|
||||
|
||||
服务端负责:
|
||||
|
||||
- 上传对象
|
||||
- 生成对象 key
|
||||
- 在下载或预览时生成短期签名 URL
|
||||
|
||||
对象 key 建议结构:
|
||||
|
||||
```text
|
||||
<prefix>/<account>/<yyyy>/<mm>/<messageId>-<safeFileName>
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `prefix` 默认为 `boss/`
|
||||
- 用户可在配置中改为其他目录前缀
|
||||
|
||||
### 6.5 统一下载入口
|
||||
|
||||
前台不直接拼 OSS URL,也不直接暴露本地文件路径。
|
||||
|
||||
统一下载/预览入口建议为:
|
||||
|
||||
```text
|
||||
GET /api/v1/attachments/[attachmentId]/download
|
||||
```
|
||||
|
||||
该接口统一负责:
|
||||
|
||||
- 鉴权当前用户是否可访问对应会话
|
||||
- 如果是 `server_file`:
|
||||
- 流式返回文件
|
||||
- 如果是 `aliyun_oss`:
|
||||
- 生成短期签名 URL
|
||||
- 302 跳转,或由服务端转发流式响应
|
||||
|
||||
这样可以保证:
|
||||
|
||||
- 客户端体验统一
|
||||
- 存储切换不影响聊天 UI
|
||||
- 不暴露底层凭证
|
||||
|
||||
## 7. 附件消息模型
|
||||
|
||||
### 7.1 新消息类型
|
||||
|
||||
现有消息类型只有文本和若干 intent 占位,不足以表达真实附件。
|
||||
|
||||
本次应扩展消息模型:
|
||||
|
||||
```ts
|
||||
type MessageKind =
|
||||
| "text"
|
||||
| "voice_intent"
|
||||
| "image_intent"
|
||||
| "video_intent"
|
||||
| "forward_notice"
|
||||
| "forward_single"
|
||||
| "forward_bundle"
|
||||
| "attachment";
|
||||
```
|
||||
|
||||
### 7.2 附件元数据
|
||||
|
||||
```ts
|
||||
type AttachmentKind =
|
||||
| "image"
|
||||
| "video"
|
||||
| "pdf"
|
||||
| "text"
|
||||
| "office"
|
||||
| "binary";
|
||||
|
||||
type AttachmentStorageBackend = "server_file" | "aliyun_oss";
|
||||
|
||||
type AttachmentAnalysisState =
|
||||
| "not_applicable"
|
||||
| "queued_auto"
|
||||
| "ready_manual"
|
||||
| "processing"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
interface MessageAttachment {
|
||||
attachmentId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSizeBytes: number;
|
||||
attachmentKind: AttachmentKind;
|
||||
storageBackend: AttachmentStorageBackend;
|
||||
storagePath: string;
|
||||
previewAvailable: boolean;
|
||||
uploadedAt: string;
|
||||
uploadedBy: string;
|
||||
analysisState: AttachmentAnalysisState;
|
||||
analysisSummary?: string;
|
||||
analysisCardId?: string;
|
||||
}
|
||||
```
|
||||
|
||||
每条附件消息可以先只支持单附件,避免第一轮就把消息结构做成多附件复合体。后续若要支持一条消息多个附件,再扩展成数组。
|
||||
|
||||
### 7.3 附件消息结构
|
||||
|
||||
```ts
|
||||
interface Message {
|
||||
id: string;
|
||||
sender: MessageSender;
|
||||
senderLabel: string;
|
||||
body: string;
|
||||
sentAt: string;
|
||||
kind?: MessageKind;
|
||||
attachment?: MessageAttachment;
|
||||
forwardSource?: ForwardSource;
|
||||
forwardBundle?: ForwardBundlePayload;
|
||||
}
|
||||
```
|
||||
|
||||
附件消息的 `body` 用作聊天摘要和兼容展示,例如:
|
||||
|
||||
- 图片:`已发送图片:车间异常截图.png`
|
||||
- 视频:`已发送视频:工位巡检录像.mp4`
|
||||
- 文件:`已发送文件:北区回归报告.pdf`
|
||||
|
||||
## 8. AI 分析模型
|
||||
|
||||
### 8.1 分析任务
|
||||
|
||||
附件分析统一走主 Agent,不让单个普通线程自行处理。
|
||||
|
||||
建议新增任务模型:
|
||||
|
||||
```ts
|
||||
type MasterAgentTaskType =
|
||||
| "chat_reply"
|
||||
| "attachment_analysis";
|
||||
|
||||
interface AttachmentAnalysisTaskPayload {
|
||||
projectId: string;
|
||||
messageId: string;
|
||||
attachmentId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
attachmentKind: AttachmentKind;
|
||||
fileSizeBytes: number;
|
||||
downloadUrl: string;
|
||||
triggerMode: "auto" | "manual";
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 自动 / 手动分析规则
|
||||
|
||||
自动进入主 Agent 的条件:
|
||||
|
||||
- `attachmentKind=image`
|
||||
- `attachmentKind=pdf`
|
||||
- `attachmentKind=text`
|
||||
- 文件大小 `<= 20 MB`
|
||||
|
||||
手动触发的条件:
|
||||
|
||||
- `attachmentKind=video`
|
||||
- `attachmentKind=office`
|
||||
- 文件大小 `> 20 MB`
|
||||
|
||||
### 8.3 AI 处理执行者
|
||||
|
||||
统一执行者为主 Agent:
|
||||
|
||||
- 优先走 `Master Codex Node`
|
||||
- 只有在容灾路径明确支持时,才考虑 `openai_api`
|
||||
|
||||
在能力未覆盖的情况下,应返回明确状态,不要假装已完成分析。
|
||||
|
||||
### 8.4 分析结果回写
|
||||
|
||||
分析结果固定回写到当前聊天会话中,形式为:
|
||||
|
||||
1. 一条简短主 Agent 回复
|
||||
2. 一张分析结果卡片
|
||||
|
||||
简短回复示例:
|
||||
|
||||
```text
|
||||
主 Agent:已分析《北区回归报告.pdf》。核心问题是登录态恢复链和 OTA 覆盖安装文档不一致。
|
||||
```
|
||||
|
||||
分析卡片中至少应包含:
|
||||
|
||||
- 分析对象文件名
|
||||
- 分析模式(自动 / 手动)
|
||||
- 核心结论摘要
|
||||
- 关键提取点列表
|
||||
- 分析完成时间
|
||||
|
||||
## 9. API 设计
|
||||
|
||||
### 9.1 Web 配置接口
|
||||
|
||||
新增用户级附件存储配置接口:
|
||||
|
||||
```text
|
||||
GET /api/v1/storage/config
|
||||
PATCH /api/v1/storage/config
|
||||
POST /api/v1/storage/config/validate
|
||||
```
|
||||
|
||||
语义:
|
||||
|
||||
- `GET`:获取当前用户的附件与存储配置
|
||||
- `PATCH`:更新当前用户配置
|
||||
- `validate`:校验阿里 OSS 是否配置可用
|
||||
|
||||
`validate` 至少检查:
|
||||
|
||||
- AK/SK 是否能通过认证
|
||||
- Bucket 是否存在
|
||||
- Endpoint / Region 是否匹配
|
||||
- 是否具备读写权限
|
||||
|
||||
### 9.2 附件上传接口
|
||||
|
||||
新增统一附件上传接口:
|
||||
|
||||
```text
|
||||
POST /api/v1/projects/[projectId]/attachments
|
||||
```
|
||||
|
||||
建议使用 `multipart/form-data`,字段至少包括:
|
||||
|
||||
- `file`
|
||||
- `sourceType`: `image | video | file`
|
||||
|
||||
该接口负责:
|
||||
|
||||
1. 鉴权
|
||||
2. 解析文件类型
|
||||
3. 读取当前用户存储配置
|
||||
4. 上传到对应后端
|
||||
5. 生成附件消息
|
||||
6. 决定是否自动创建主 Agent 分析任务
|
||||
|
||||
返回值应直接带回新创建的消息和分析状态。
|
||||
|
||||
### 9.3 手动分析接口
|
||||
|
||||
新增:
|
||||
|
||||
```text
|
||||
POST /api/v1/projects/[projectId]/attachments/[attachmentId]/analyze
|
||||
```
|
||||
|
||||
用途:
|
||||
|
||||
- 对 `ready_manual` 的附件手动触发 AI 分析
|
||||
|
||||
### 9.4 统一下载接口
|
||||
|
||||
新增:
|
||||
|
||||
```text
|
||||
GET /api/v1/attachments/[attachmentId]/download
|
||||
```
|
||||
|
||||
用途:
|
||||
|
||||
- 图片预览
|
||||
- 视频预览或下载
|
||||
- 文件下载
|
||||
|
||||
### 9.5 SSE 刷新
|
||||
|
||||
现有 `/api/v1/events` 继续承担刷新出口,需要增加这些事件:
|
||||
|
||||
- `attachment.uploaded`
|
||||
- `attachment.analysis.queued`
|
||||
- `attachment.analysis.updated`
|
||||
- `attachment.analysis.completed`
|
||||
- `attachment.analysis.failed`
|
||||
|
||||
## 10. 原生 Android 设计
|
||||
|
||||
### 10.1 聊天输入区
|
||||
|
||||
聊天输入区固定为:
|
||||
|
||||
- 左侧 `+`
|
||||
- 中间输入框
|
||||
- 右侧发送按钮
|
||||
|
||||
点击 `+` 后弹出底部抽屉,包含:
|
||||
|
||||
- 图片
|
||||
- 视频
|
||||
- 文件
|
||||
|
||||
### 10.2 系统文件选择
|
||||
|
||||
原生 Android 需要新增:
|
||||
|
||||
- 图片选择器
|
||||
- 视频选择器
|
||||
- 文件选择器
|
||||
|
||||
建议基于 `ActivityResultContracts.OpenDocument` / `GetContent` 实现。
|
||||
|
||||
### 10.3 发送前确认
|
||||
|
||||
- 图片:展示预览 + `取消 / 发送`
|
||||
- 视频:展示预览或文件摘要 + `取消 / 发送`
|
||||
- 文件:直接进入上传
|
||||
|
||||
### 10.4 聊天中的附件渲染
|
||||
|
||||
图片消息:
|
||||
|
||||
- 缩略图
|
||||
- 文件名
|
||||
- 大小
|
||||
- 分析状态
|
||||
|
||||
视频消息:
|
||||
|
||||
- 缩略图或占位封面
|
||||
- 文件名
|
||||
- 大小
|
||||
- 分析状态
|
||||
|
||||
文件消息:
|
||||
|
||||
- 文件图标
|
||||
- 文件名
|
||||
- 类型
|
||||
- 大小
|
||||
- 下载/查看动作
|
||||
- 分析状态
|
||||
|
||||
### 10.5 分析状态呈现
|
||||
|
||||
附件卡片需能表示:
|
||||
|
||||
- `queued_auto`:自动分析排队中
|
||||
- `ready_manual`:可分析
|
||||
- `processing`:分析中
|
||||
- `completed`:分析完成
|
||||
- `failed`:分析失败,可重试
|
||||
|
||||
## 11. Web 配置页设计
|
||||
|
||||
### 11.1 入口
|
||||
|
||||
入口固定为:
|
||||
|
||||
```text
|
||||
我的 > 附件与存储
|
||||
```
|
||||
|
||||
### 11.2 页面结构
|
||||
|
||||
第一页只做两层:
|
||||
|
||||
1. 存储方式选择
|
||||
- 服务器文件存储(默认)
|
||||
- OSS
|
||||
2. 如果选 `OSS`
|
||||
- 供应商选择:当前只显示 `阿里 OSS`
|
||||
- 展开最小配置表单
|
||||
|
||||
### 11.3 表单交互
|
||||
|
||||
表单交互应尽可能简化:
|
||||
|
||||
- `AccessKey ID`
|
||||
- `AccessKey Secret`
|
||||
- `Bucket`
|
||||
- `Endpoint`
|
||||
- `Region`
|
||||
- `目录前缀(可选)`
|
||||
|
||||
按钮建议只有两个:
|
||||
|
||||
- `测试并保存`
|
||||
- `切回服务器文件存储`
|
||||
|
||||
不做额外的高级设置入口作为第一屏主内容。
|
||||
|
||||
## 12. 与主 Agent 的关系
|
||||
|
||||
主 Agent 不再只处理文本请求,也需要能理解附件分析任务。
|
||||
|
||||
这次要求主 Agent:
|
||||
|
||||
1. 收到附件任务时,知道当前附件来自哪个会话、哪条消息
|
||||
2. 能通过统一下载 URL 获取附件
|
||||
3. 对图片 / PDF / 文本自动分析
|
||||
4. 对手动模式附件在用户触发后再分析
|
||||
5. 把结果回写为:
|
||||
- 一条简短消息
|
||||
- 一张分析卡片
|
||||
|
||||
如果主 Agent 当前无法处理某类附件,应明确返回失败原因,而不是静默成功。
|
||||
|
||||
## 13. 错误处理
|
||||
|
||||
至少覆盖以下错误:
|
||||
|
||||
1. 用户未配置 OSS,但切换到了 OSS
|
||||
2. OSS 配置无效
|
||||
3. Bucket 无权限
|
||||
4. 上传中断
|
||||
5. 本地文件 URI 无法读取
|
||||
6. 附件消息已创建但自动分析排队失败
|
||||
7. 分析任务执行失败
|
||||
8. 下载 URL 失效
|
||||
9. 原文件已被删除或不可用
|
||||
|
||||
错误处理原则:
|
||||
|
||||
- 上传失败:不创建成功消息,前台保留失败提示
|
||||
- 上传成功、分析失败:保留附件消息,但把分析状态标为 `failed`
|
||||
- 下载失败:允许用户重试,不直接删除消息
|
||||
|
||||
## 14. 测试与验收
|
||||
|
||||
### 14.1 Web / 服务端
|
||||
|
||||
至少验证:
|
||||
|
||||
1. 默认服务器文件存储上传成功
|
||||
2. 阿里 OSS 上传成功
|
||||
3. 用户级配置切换生效
|
||||
4. 未配置 OSS 时不能误走 OSS
|
||||
5. 图片 / PDF / 文本能自动排队分析
|
||||
6. 视频 / Office / 大文件默认进入手动分析
|
||||
7. 下载接口能正确返回本地文件或 OSS 签名 URL
|
||||
|
||||
### 14.2 原生 Android
|
||||
|
||||
至少验证:
|
||||
|
||||
1. `+` 按钮打开底部抽屉
|
||||
2. 图片发送前确认
|
||||
3. 视频发送前确认
|
||||
4. 文件直接发送
|
||||
5. 图片消息渲染
|
||||
6. 视频消息渲染
|
||||
7. 文件消息渲染
|
||||
8. 分析状态切换
|
||||
9. 下载 / 预览动作
|
||||
|
||||
### 14.3 验收标准
|
||||
|
||||
本次工作完成时,应满足:
|
||||
|
||||
1. 用户能从原生聊天框发送图片、视频、文件
|
||||
2. 接收端能看到附件消息并统一预览/下载
|
||||
3. 默认服务器文件存储可直接使用
|
||||
4. 用户可在 Web 端开启 OSS,并完成阿里 OSS 最小配置
|
||||
5. 图片 / PDF / 文本会自动交给主 Agent 分析
|
||||
6. 视频 / Office / 大文件可手动触发分析
|
||||
7. 主 Agent 的分析结果会回到当前聊天中
|
||||
8. UI 和交互仍保持当前微信式方向,不回退成控制台面板
|
||||
|
||||
## 15. 推荐实现顺序
|
||||
|
||||
1. 先补数据模型与用户级存储配置
|
||||
2. 再补服务器文件存储上传 / 下载链
|
||||
3. 再补阿里 OSS 适配器与校验
|
||||
4. 再补原生 Android 选择文件、上传和附件卡片
|
||||
5. 再把主 Agent 附件分析任务接通
|
||||
6. 最后补 Web 配置页、联调、部署与发包
|
||||
1194
package-lock.json
generated
1194
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,13 +19,16 @@
|
||||
"@capacitor/cli": "^8.2.0",
|
||||
"@capacitor/core": "^8.2.0",
|
||||
"@capacitor/preferences": "^8.0.1",
|
||||
"ali-oss": "^6.23.0",
|
||||
"clsx": "^2.1.1",
|
||||
"next": "16.2.1",
|
||||
"proxy-agent": "^5.0.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/ali-oss": "^6.23.3",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"artifactType": "aab",
|
||||
"fileName": "boss-android-v2.4.0-release.aab",
|
||||
"urlPath": "/downloads/boss-android-v2.4.0-release.aab",
|
||||
"sizeBytes": 2894817,
|
||||
"updatedAt": "2026-03-28T00:59:18Z",
|
||||
"sha256": "beaca830a470bb5af180cb75dff60fc9e2039f10214480ae0cd7503bd793af22",
|
||||
"versionName": "2.4.0",
|
||||
"versionCode": 12,
|
||||
"fileName": "boss-android-v2.5.0-release.aab",
|
||||
"urlPath": "/downloads/boss-android-v2.5.0-release.aab",
|
||||
"sizeBytes": 2906325,
|
||||
"updatedAt": "2026-03-29T09:19:37Z",
|
||||
"sha256": "e230a1e0eae8e1e6d264f11feb3125ff40661dc2e049e18d8f683c3571e3a568",
|
||||
"versionName": "2.5.0",
|
||||
"versionCode": 13,
|
||||
"buildFlavor": "release"
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"fileName": "boss-android-v2.4.0-release.apk",
|
||||
"fileName": "boss-android-v2.5.0-release.apk",
|
||||
"urlPath": "/api/v1/user/ota/package",
|
||||
"sizeBytes": 3071649,
|
||||
"updatedAt": "2026-03-28T00:59:14Z",
|
||||
"sha256": "34831f13e8458fe668f7358c6a0d37d39430d3b8002a6c998b445b40f99e3672",
|
||||
"versionName": "2.4.0",
|
||||
"versionCode": 12,
|
||||
"sizeBytes": 3083087,
|
||||
"updatedAt": "2026-03-29T09:20:05Z",
|
||||
"sha256": "92183a0ebed80da5e363ffcd8e41a4acfd650aac76072ac5a4e432af5902f59f",
|
||||
"versionName": "2.5.0",
|
||||
"versionCode": 13,
|
||||
"buildFlavor": "release"
|
||||
}
|
||||
|
||||
BIN
public/downloads/boss-android-v2.5.0-release.aab
Normal file
BIN
public/downloads/boss-android-v2.5.0-release.aab
Normal file
Binary file not shown.
BIN
public/downloads/boss-android-v2.5.0-release.apk
Normal file
BIN
public/downloads/boss-android-v2.5.0-release.apk
Normal file
Binary file not shown.
253
scripts/validate-attachment-analysis.mjs
Normal file
253
scripts/validate-attachment-analysis.mjs
Normal file
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const taskBaseCommit = "3307f7916220b74a8e7d0d8e8b2b12f888d0632a";
|
||||
const sourceStateFile = path.join(rootDir, "data", "boss-state.json");
|
||||
|
||||
async function createSeededRuntime(root, runtimeName) {
|
||||
const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), runtimeName));
|
||||
const stateFile = path.join(runtimeDir, "data", "boss-state.json");
|
||||
await fs.mkdir(path.join(runtimeDir, "data", "uploads"), { recursive: true });
|
||||
await fs.mkdir(path.join(runtimeDir, "public", "downloads"), { recursive: true });
|
||||
const state = JSON.parse(await fs.readFile(sourceStateFile, "utf8"));
|
||||
await fs.writeFile(stateFile, JSON.stringify(state, null, 2), "utf8");
|
||||
return { runtimeDir, stateFile };
|
||||
}
|
||||
|
||||
function parseCookieValue(setCookieHeader, cookieName) {
|
||||
assert.ok(setCookieHeader, "set-cookie header is missing");
|
||||
const match = setCookieHeader.match(new RegExp(`${cookieName}=([^;]+)`));
|
||||
assert.ok(match, `${cookieName} cookie is missing`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
async function waitForServer(baseUrl, child, getServerLogs) {
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`SERVER_EXITED_EARLY:${child.exitCode}:${getServerLogs()}`);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/health`);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// keep waiting
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`SERVER_START_TIMEOUT:${getServerLogs()}`);
|
||||
}
|
||||
|
||||
async function startStandaloneServer(appRoot, runtimeDir, port) {
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
let logs = "";
|
||||
const child = spawn("node", [".next/standalone/server.js"], {
|
||||
cwd: appRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
HOSTNAME: "127.0.0.1",
|
||||
BOSS_PUBLIC_BASE_URL: baseUrl,
|
||||
BOSS_RUNTIME_ROOT: runtimeDir,
|
||||
BOSS_STATE_FILE: path.join(runtimeDir, "data", "boss-state.json"),
|
||||
BOSS_AUTH_AUTO_LOGIN: "0",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
logs += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
logs += chunk.toString();
|
||||
});
|
||||
|
||||
await waitForServer(baseUrl, child, () => logs);
|
||||
return {
|
||||
baseUrl,
|
||||
child,
|
||||
getLogs: () => logs,
|
||||
async stop() {
|
||||
if (child.exitCode === null) {
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function loginAsAdmin(baseUrl) {
|
||||
const response = await fetch(`${baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
account: "17600003315",
|
||||
password: "boss123456",
|
||||
method: "password",
|
||||
}),
|
||||
});
|
||||
assert.equal(response.status, 200, "login should succeed");
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.ok, true, "login payload should be ok");
|
||||
const cookie = parseCookieValue(response.headers.get("set-cookie"), "boss_session");
|
||||
return { cookie, payload };
|
||||
}
|
||||
|
||||
async function uploadAttachment(baseUrl, cookie, projectId, fileName, type, bytes) {
|
||||
const form = new FormData();
|
||||
form.set("file", new File([bytes], fileName, { type }));
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/v1/projects/${projectId}/attachments`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: `boss_session=${cookie}`,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
assert.equal(response.status, 200, `upload ${fileName} should succeed`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function analyzeAttachment(baseUrl, cookie, projectId, attachmentId) {
|
||||
const response = await fetch(`${baseUrl}/api/v1/projects/${projectId}/attachments/${attachmentId}/analyze`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: `boss_session=${cookie}`,
|
||||
},
|
||||
});
|
||||
assert.equal(response.status, 200, "manual analyze should succeed");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function verifyHistoricalPrecheck() {
|
||||
const worktreePath = await fs.mkdtemp(path.join(os.tmpdir(), "boss-attachment-precheck-"));
|
||||
let server;
|
||||
try {
|
||||
await execFile("git", ["worktree", "add", "--detach", worktreePath, taskBaseCommit], {
|
||||
cwd: rootDir,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
await execFile("npm", ["ci", "--ignore-scripts", "--no-audit", "--no-fund"], {
|
||||
cwd: worktreePath,
|
||||
env: process.env,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
await execFile("npm", ["run", "build"], {
|
||||
cwd: worktreePath,
|
||||
env: process.env,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const { runtimeDir } = await createSeededRuntime(worktreePath, "boss-attachment-precheck-");
|
||||
server = await startStandaloneServer(worktreePath, runtimeDir, 3115);
|
||||
const { cookie } = await loginAsAdmin(server.baseUrl);
|
||||
const response = await fetch(`${server.baseUrl}/api/v1/projects/master-agent/attachments/att-missing/analyze`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: `boss_session=${cookie}`,
|
||||
},
|
||||
});
|
||||
assert.notEqual(response.status, 200, "pre-implementation analyze route should not succeed");
|
||||
} finally {
|
||||
await server?.stop();
|
||||
await execFile("git", ["worktree", "remove", "--force", worktreePath], {
|
||||
cwd: rootDir,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
}).catch(() => undefined);
|
||||
await fs.rm(worktreePath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
await verifyHistoricalPrecheck();
|
||||
|
||||
const { runtimeDir } = await createSeededRuntime(rootDir, "boss-attachment-current-");
|
||||
const currentServer = await startStandaloneServer(rootDir, runtimeDir, 3116);
|
||||
|
||||
try {
|
||||
const { cookie } = await loginAsAdmin(currentServer.baseUrl);
|
||||
|
||||
const textUpload = await uploadAttachment(
|
||||
currentServer.baseUrl,
|
||||
cookie,
|
||||
"master-agent",
|
||||
"analysis-note.txt",
|
||||
"text/plain",
|
||||
Buffer.from("text attachment for automatic analysis"),
|
||||
);
|
||||
assert.equal(textUpload.attachment.analysisState, "queued_auto", "text attachment should queue automatically");
|
||||
assert.ok(textUpload.analysisTask, "queued auto attachment should create a master agent task");
|
||||
assert.equal(
|
||||
textUpload.analysisTask.taskType,
|
||||
"attachment_analysis",
|
||||
"queued task type should be attachment_analysis",
|
||||
);
|
||||
assert.equal(
|
||||
textUpload.analysisTask.attachmentFileName,
|
||||
"analysis-note.txt",
|
||||
"queued task should carry attachment file name",
|
||||
);
|
||||
assert.ok(textUpload.analysisTask.attachmentDownloadUrl, "queued task should expose attachment download url");
|
||||
assert.ok(
|
||||
textUpload.analysisTask.attachmentDownloadUrl.startsWith(currentServer.baseUrl),
|
||||
"queued task should use the current runtime origin for attachment download",
|
||||
);
|
||||
const promptDownloadUrlMatch = textUpload.analysisTask.executionPrompt.match(/downloadUrl:\s+(http[^\s]+)/);
|
||||
assert.ok(promptDownloadUrlMatch, "execution prompt should include attachment download url");
|
||||
const unauthDownloadResponse = await fetch(textUpload.analysisTask.attachmentDownloadUrl);
|
||||
assert.equal(unauthDownloadResponse.status, 200, "attachment download url should be readable with task token");
|
||||
assert.equal(
|
||||
await unauthDownloadResponse.text(),
|
||||
"text attachment for automatic analysis",
|
||||
"downloaded attachment content should match the uploaded text",
|
||||
);
|
||||
|
||||
const manualUpload = await uploadAttachment(
|
||||
currentServer.baseUrl,
|
||||
cookie,
|
||||
"master-agent",
|
||||
"manual-binary.bin",
|
||||
"application/octet-stream",
|
||||
Buffer.from([0, 1, 2, 3]),
|
||||
);
|
||||
assert.equal(
|
||||
manualUpload.attachment.analysisState,
|
||||
"ready_manual",
|
||||
"binary attachment should be manually analyzable",
|
||||
);
|
||||
|
||||
const analyzePayload = await analyzeAttachment(
|
||||
currentServer.baseUrl,
|
||||
cookie,
|
||||
"master-agent",
|
||||
manualUpload.attachment.attachmentId,
|
||||
);
|
||||
assert.ok(analyzePayload.taskId, "manual analyze should return a taskId");
|
||||
assert.ok(analyzePayload.task, "manual analyze should return a task payload");
|
||||
assert.equal(
|
||||
analyzePayload.task.taskType,
|
||||
"attachment_analysis",
|
||||
"manual analyze task should be attachment_analysis",
|
||||
);
|
||||
assert.equal(
|
||||
analyzePayload.task.attachmentId,
|
||||
manualUpload.attachment.attachmentId,
|
||||
"manual task should link the attachment",
|
||||
);
|
||||
|
||||
console.log("attachment analysis validation passed");
|
||||
} finally {
|
||||
await currentServer.stop();
|
||||
await fs.rm(runtimeDir, { recursive: true, force: true });
|
||||
}
|
||||
286
scripts/verify-attachment-security.mjs
Normal file
286
scripts/verify-attachment-security.mjs
Normal file
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env node
|
||||
import { randomBytes, scryptSync } from "node:crypto";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
|
||||
function hashPassword(password) {
|
||||
const normalized = password.normalize("NFKC");
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync(normalized, `boss:${salt}`, 64).toString("hex");
|
||||
return `scrypt$${salt}$${hash}`;
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function projectTemplate(id, name, deviceId) {
|
||||
const timestamp = nowIso();
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
pinned: false,
|
||||
deviceIds: [deviceId],
|
||||
preview: "",
|
||||
updatedAt: timestamp,
|
||||
lastMessageAt: timestamp,
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId: id,
|
||||
threadId: `thread-${id}`,
|
||||
threadDisplayName: name,
|
||||
folderName: name,
|
||||
activityIconCount: 1,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: false,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
unreadCount: 0,
|
||||
riskLevel: "low",
|
||||
messages: [],
|
||||
goals: [],
|
||||
versions: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForServer(baseUrl, child, getServerLogs) {
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`SERVER_EXITED_EARLY:${child.exitCode}:${getServerLogs()}`);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/health`);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`SERVER_START_TIMEOUT:${getServerLogs()}`);
|
||||
}
|
||||
|
||||
async function login(baseUrl, account, password) {
|
||||
const response = await fetch(`${baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
account,
|
||||
password,
|
||||
method: "password",
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`LOGIN_FAILED:${account}:${response.status}`);
|
||||
}
|
||||
const cookie = (response.headers.get("set-cookie") || "").split(";")[0];
|
||||
if (!cookie) {
|
||||
throw new Error(`COOKIE_MISSING:${account}`);
|
||||
}
|
||||
return cookie;
|
||||
}
|
||||
|
||||
const tmpRoot = await mkdtemp(path.join(os.tmpdir(), "boss-attachment-security-"));
|
||||
const runtimeRoot = path.join(tmpRoot, "runtime");
|
||||
const dataDir = path.join(runtimeRoot, "data");
|
||||
const uploadsDir = path.join(dataDir, "uploads");
|
||||
const stateFile = path.join(dataDir, "boss-state.json");
|
||||
const outsideSecretPath = path.join(runtimeRoot, "secret-outside-uploads.txt");
|
||||
const port = "3104";
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
|
||||
let server;
|
||||
|
||||
try {
|
||||
await mkdir(path.join(runtimeRoot, "public", "downloads"), { recursive: true });
|
||||
await mkdir(uploadsDir, { recursive: true });
|
||||
|
||||
const baseState = JSON.parse(await readFile(path.join(repoRoot, "data", "boss-state.json"), "utf8"));
|
||||
const timestamp = nowIso();
|
||||
const memberAccount = "18800000001";
|
||||
const memberPassword = "member-pass-123";
|
||||
const memberDeviceId = "member-device-1";
|
||||
const memberProject = projectTemplate("member-project", "成员项目", memberDeviceId);
|
||||
const adminProject = projectTemplate("admin-only-project", "管理员项目", "mac-studio");
|
||||
|
||||
const adminAttachmentPath = path.join("data", "uploads", "seeded-admin", "2026", "03", "admin-note.txt");
|
||||
const adminAbsolutePath = path.join(runtimeRoot, adminAttachmentPath);
|
||||
await mkdir(path.dirname(adminAbsolutePath), { recursive: true });
|
||||
await writeFile(adminAbsolutePath, "admin only attachment\n", "utf8");
|
||||
|
||||
const traversalAttachmentId = "att-traversal";
|
||||
const adminAttachmentId = "att-admin-only";
|
||||
await writeFile(outsideSecretPath, "outside uploads secret\n", "utf8");
|
||||
|
||||
memberProject.messages.push({
|
||||
id: "msg-traversal",
|
||||
sender: "user",
|
||||
senderLabel: "成员用户",
|
||||
body: "traversal probe",
|
||||
sentAt: timestamp,
|
||||
kind: "attachment",
|
||||
attachments: [
|
||||
{
|
||||
attachmentId: traversalAttachmentId,
|
||||
fileName: "secret.txt",
|
||||
mimeType: "text/plain",
|
||||
fileSizeBytes: 23,
|
||||
attachmentKind: "text",
|
||||
storageBackend: "server_file",
|
||||
storagePath: outsideSecretPath,
|
||||
previewAvailable: false,
|
||||
uploadedAt: timestamp,
|
||||
uploadedBy: memberAccount,
|
||||
analysisState: "ready_manual",
|
||||
},
|
||||
],
|
||||
});
|
||||
memberProject.preview = "traversal probe";
|
||||
|
||||
adminProject.messages.push({
|
||||
id: "msg-admin-attachment",
|
||||
sender: "user",
|
||||
senderLabel: "Boss 超级管理员",
|
||||
body: "管理员附件",
|
||||
sentAt: timestamp,
|
||||
kind: "attachment",
|
||||
attachments: [
|
||||
{
|
||||
attachmentId: adminAttachmentId,
|
||||
fileName: "admin-note.txt",
|
||||
mimeType: "text/plain",
|
||||
fileSizeBytes: 22,
|
||||
attachmentKind: "text",
|
||||
storageBackend: "server_file",
|
||||
storagePath: adminAttachmentPath,
|
||||
previewAvailable: false,
|
||||
uploadedAt: timestamp,
|
||||
uploadedBy: "17600003315",
|
||||
analysisState: "ready_manual",
|
||||
},
|
||||
],
|
||||
});
|
||||
adminProject.preview = "管理员附件";
|
||||
|
||||
baseState.authSessions = [];
|
||||
baseState.authAccounts.push({
|
||||
id: `account-${memberAccount}`,
|
||||
account: memberAccount,
|
||||
passwordHash: hashPassword(memberPassword),
|
||||
displayName: "成员用户",
|
||||
role: "member",
|
||||
primaryDeviceId: memberDeviceId,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
baseState.devices.push({
|
||||
id: memberDeviceId,
|
||||
name: "Member Device",
|
||||
avatar: "M",
|
||||
account: memberAccount,
|
||||
source: "production",
|
||||
status: "online",
|
||||
projects: [memberProject.id],
|
||||
quota5h: 10,
|
||||
quota7d: 20,
|
||||
lastSeenAt: timestamp,
|
||||
});
|
||||
baseState.projects.push(memberProject, adminProject);
|
||||
baseState.userAttachmentStorageConfigs = [
|
||||
...(Array.isArray(baseState.userAttachmentStorageConfigs) ? baseState.userAttachmentStorageConfigs : []),
|
||||
{
|
||||
account: memberAccount,
|
||||
mode: "server_file",
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
];
|
||||
|
||||
await writeFile(stateFile, JSON.stringify(baseState, null, 2), "utf8");
|
||||
|
||||
server = spawn("node", [".next/standalone/server.js"], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: port,
|
||||
HOSTNAME: "127.0.0.1",
|
||||
BOSS_RUNTIME_ROOT: runtimeRoot,
|
||||
BOSS_STATE_FILE: stateFile,
|
||||
BOSS_AUTH_AUTO_LOGIN: "0",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let serverLogs = "";
|
||||
server.stdout.on("data", (chunk) => {
|
||||
serverLogs += chunk.toString();
|
||||
});
|
||||
server.stderr.on("data", (chunk) => {
|
||||
serverLogs += chunk.toString();
|
||||
});
|
||||
|
||||
await waitForServer(baseUrl, server, () => serverLogs);
|
||||
|
||||
const memberCookie = await login(baseUrl, memberAccount, memberPassword);
|
||||
const adminCookie = await login(baseUrl, "17600003315", "boss123456");
|
||||
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.append("file", new File([Buffer.from("blocked upload\n")], "blocked.txt", { type: "text/plain" }));
|
||||
|
||||
const uploadDenied = await fetch(`${baseUrl}/api/v1/projects/admin-only-project/attachments`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: memberCookie,
|
||||
},
|
||||
body: uploadForm,
|
||||
});
|
||||
if (uploadDenied.status !== 403) {
|
||||
throw new Error(`EXPECTED_UPLOAD_403:${uploadDenied.status}`);
|
||||
}
|
||||
|
||||
const adminDownload = await fetch(`${baseUrl}/api/v1/attachments/${adminAttachmentId}/download`, {
|
||||
headers: {
|
||||
cookie: memberCookie,
|
||||
},
|
||||
});
|
||||
if (adminDownload.status !== 403) {
|
||||
throw new Error(`EXPECTED_DOWNLOAD_403:${adminDownload.status}`);
|
||||
}
|
||||
|
||||
const happyAdminDownload = await fetch(`${baseUrl}/api/v1/attachments/${adminAttachmentId}/download`, {
|
||||
headers: {
|
||||
cookie: adminCookie,
|
||||
},
|
||||
});
|
||||
if (!happyAdminDownload.ok) {
|
||||
throw new Error(`ADMIN_DOWNLOAD_FAILED:${happyAdminDownload.status}`);
|
||||
}
|
||||
|
||||
const traversalDownload = await fetch(`${baseUrl}/api/v1/attachments/${traversalAttachmentId}/download`, {
|
||||
headers: {
|
||||
cookie: memberCookie,
|
||||
},
|
||||
});
|
||||
if (traversalDownload.status !== 404) {
|
||||
throw new Error(`EXPECTED_TRAVERSAL_404:${traversalDownload.status}`);
|
||||
}
|
||||
|
||||
console.log("OK");
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (server && server.exitCode === null) {
|
||||
server.kill("SIGTERM");
|
||||
await new Promise((resolve) => server.once("exit", resolve));
|
||||
}
|
||||
await rm(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
80
scripts/verify-attachment-storage-model.mjs
Normal file
80
scripts/verify-attachment-storage-model.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
import { mkdtemp, copyFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
const jiti = require("jiti")(fileURLToPath(import.meta.url), {
|
||||
alias: {
|
||||
"@/": `${path.join(rootDir, "src")}/`,
|
||||
},
|
||||
});
|
||||
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "boss-attachment-model-"));
|
||||
const tempStateFile = path.join(tempDir, "boss-state.json");
|
||||
const sourceStateFile = path.join(rootDir, "data", "boss-state.json");
|
||||
|
||||
if (!existsSync(sourceStateFile)) {
|
||||
throw new Error(`Missing state file: ${sourceStateFile}`);
|
||||
}
|
||||
|
||||
await copyFile(sourceStateFile, tempStateFile);
|
||||
process.env.BOSS_STATE_FILE = tempStateFile;
|
||||
process.env.BOSS_RUNTIME_ROOT = rootDir;
|
||||
|
||||
const { getAttachmentStorageConfig, readState, writeState } = jiti(path.join(scriptDir, "..", "src", "lib", "boss-data.ts"));
|
||||
|
||||
const config = await getAttachmentStorageConfig("17600003315");
|
||||
if (config.mode !== "server_file") {
|
||||
throw new Error(`Expected default storage mode server_file, got ${config.mode}`);
|
||||
}
|
||||
if (!config.updatedAt || typeof config.updatedAt !== "string") {
|
||||
throw new Error("Expected updatedAt to be populated");
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
const messageId = "script-attachment-message";
|
||||
state.projects[0].messages.unshift({
|
||||
id: messageId,
|
||||
sender: "user",
|
||||
senderLabel: "测试用户",
|
||||
body: "Attachment round-trip",
|
||||
sentAt: "2026-03-29T00:00:00+08:00",
|
||||
kind: "attachment",
|
||||
attachments: [
|
||||
{
|
||||
attachmentId: "att-001",
|
||||
fileName: "demo.txt",
|
||||
mimeType: "text/plain",
|
||||
fileSizeBytes: 12,
|
||||
attachmentKind: "text",
|
||||
storageBackend: "server_file",
|
||||
storagePath: "/tmp/demo.txt",
|
||||
previewAvailable: true,
|
||||
uploadedAt: "2026-03-29T00:00:00+08:00",
|
||||
uploadedBy: "17600003315",
|
||||
analysisState: "not_applicable",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await writeState(state);
|
||||
const reread = await readState();
|
||||
const message = reread.projects[0].messages.find((item) => item.id === messageId);
|
||||
|
||||
if (!message?.attachments?.length) {
|
||||
throw new Error("Expected message attachments to round-trip through state");
|
||||
}
|
||||
if (message.attachments[0].attachmentId !== "att-001") {
|
||||
throw new Error("Expected attachment metadata to persist");
|
||||
}
|
||||
if (message.attachments[0].storageBackend !== "server_file") {
|
||||
throw new Error("Expected attachment storage backend to persist");
|
||||
}
|
||||
|
||||
console.log("OK");
|
||||
73
scripts/verify-attachment-upload-download.mjs
Normal file
73
scripts/verify-attachment-upload-download.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const baseUrl = process.env.BOSS_TEST_BASE_URL || "http://127.0.0.1:3000";
|
||||
const repoRoot = process.cwd();
|
||||
const readmePath = path.join(repoRoot, "README.md");
|
||||
const readmeBytes = await readFile(readmePath);
|
||||
|
||||
const loginResponse = await fetch(`${baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!loginResponse.ok) {
|
||||
throw new Error(`LOGIN_FAILED:${loginResponse.status}`);
|
||||
}
|
||||
|
||||
const setCookie = loginResponse.headers.get("set-cookie") || "";
|
||||
const cookie = setCookie.split(";")[0];
|
||||
if (!cookie) {
|
||||
throw new Error("COOKIE_MISSING");
|
||||
}
|
||||
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.append("file", new File([readmeBytes], "README.md", { type: "text/markdown" }));
|
||||
|
||||
const uploadResponse = await fetch(`${baseUrl}/api/v1/projects/boss-console/attachments`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie,
|
||||
},
|
||||
body: uploadForm,
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`UPLOAD_FAILED:${uploadResponse.status}`);
|
||||
}
|
||||
|
||||
const uploadJson = await uploadResponse.json();
|
||||
if (!uploadJson.ok || !uploadJson.attachment?.attachmentId || !uploadJson.downloadUrl) {
|
||||
throw new Error("UPLOAD_RESPONSE_INVALID");
|
||||
}
|
||||
if (uploadJson.message?.kind !== "attachment") {
|
||||
throw new Error("ATTACHMENT_MESSAGE_KIND_INVALID");
|
||||
}
|
||||
if (!Array.isArray(uploadJson.message?.attachments) || uploadJson.message.attachments.length !== 1) {
|
||||
throw new Error("ATTACHMENT_PAYLOAD_INVALID");
|
||||
}
|
||||
|
||||
const downloadResponse = await fetch(`${baseUrl}${uploadJson.downloadUrl}`, {
|
||||
headers: {
|
||||
cookie,
|
||||
},
|
||||
});
|
||||
|
||||
if (!downloadResponse.ok) {
|
||||
throw new Error(`DOWNLOAD_FAILED:${downloadResponse.status}`);
|
||||
}
|
||||
|
||||
const downloadedBytes = Buffer.from(await downloadResponse.arrayBuffer());
|
||||
if (Buffer.compare(downloadedBytes, readmeBytes) !== 0) {
|
||||
throw new Error("DOWNLOADED_CONTENT_MISMATCH");
|
||||
}
|
||||
|
||||
if ((downloadResponse.headers.get("content-disposition") || "").indexOf("README.md") === -1) {
|
||||
throw new Error("DOWNLOAD_HEADERS_INVALID");
|
||||
}
|
||||
|
||||
console.log("OK");
|
||||
92
scripts/verify-storage-config.mjs
Normal file
92
scripts/verify-storage-config.mjs
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
const baseUrl = process.env.BOSS_TEST_BASE_URL || "http://127.0.0.1:3000";
|
||||
|
||||
const loginResponse = await fetch(`${baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!loginResponse.ok) {
|
||||
throw new Error(`LOGIN_FAILED:${loginResponse.status}`);
|
||||
}
|
||||
|
||||
const cookie = (loginResponse.headers.get("set-cookie") || "").split(";")[0];
|
||||
if (!cookie) {
|
||||
throw new Error("COOKIE_MISSING");
|
||||
}
|
||||
|
||||
const getResponse = await fetch(`${baseUrl}/api/v1/storage/config`, {
|
||||
headers: {
|
||||
cookie,
|
||||
},
|
||||
});
|
||||
|
||||
if (!getResponse.ok) {
|
||||
throw new Error(`GET_CONFIG_FAILED:${getResponse.status}`);
|
||||
}
|
||||
|
||||
const getJson = await getResponse.json();
|
||||
if (!getJson.ok || getJson.config?.mode !== "server_file") {
|
||||
throw new Error("DEFAULT_STORAGE_MODE_INVALID");
|
||||
}
|
||||
|
||||
const patchPayload = {
|
||||
mode: "server_file",
|
||||
ossProvider: "aliyun_oss",
|
||||
aliyunOss: {
|
||||
enabled: false,
|
||||
accessKeyId: "ak-test",
|
||||
accessKeySecret: "oss-secret-test",
|
||||
bucket: "boss-private-bucket",
|
||||
endpoint: "oss-cn-shanghai.aliyuncs.com",
|
||||
region: "oss-cn-shanghai",
|
||||
prefix: "boss/custom/",
|
||||
},
|
||||
};
|
||||
|
||||
const patchResponse = await fetch(`${baseUrl}/api/v1/storage/config`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
cookie,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(patchPayload),
|
||||
});
|
||||
|
||||
if (!patchResponse.ok) {
|
||||
throw new Error(`PATCH_CONFIG_FAILED:${patchResponse.status}`);
|
||||
}
|
||||
|
||||
const patchJson = await patchResponse.json();
|
||||
if (!patchJson.ok) {
|
||||
throw new Error("PATCH_CONFIG_NOT_OK");
|
||||
}
|
||||
if (patchJson.config?.aliyunOss?.accessKeySecretConfigured !== true) {
|
||||
throw new Error("SECRET_SANITIZE_FLAG_MISSING");
|
||||
}
|
||||
if ("accessKeySecretEncrypted" in (patchJson.config?.aliyunOss ?? {})) {
|
||||
throw new Error("ENCRYPTED_SECRET_LEAKED");
|
||||
}
|
||||
|
||||
const rereadResponse = await fetch(`${baseUrl}/api/v1/storage/config`, {
|
||||
headers: {
|
||||
cookie,
|
||||
},
|
||||
});
|
||||
|
||||
if (!rereadResponse.ok) {
|
||||
throw new Error(`GET_CONFIG_REREAD_FAILED:${rereadResponse.status}`);
|
||||
}
|
||||
|
||||
const rereadJson = await rereadResponse.json();
|
||||
if (rereadJson.config?.aliyunOss?.accessKeyId !== "ak-test") {
|
||||
throw new Error("PATCHED_CONFIG_NOT_PERSISTED");
|
||||
}
|
||||
if (rereadJson.config?.aliyunOss?.accessKeySecretConfigured !== true) {
|
||||
throw new Error("SECRET_FLAG_NOT_PERSISTED");
|
||||
}
|
||||
|
||||
console.log("OK");
|
||||
106
src/app/api/v1/attachments/[attachmentId]/download/route.ts
Normal file
106
src/app/api/v1/attachments/[attachmentId]/download/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { Readable } from "node:stream";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { canSessionAccessAttachmentProject } from "@/lib/boss-attachment-access";
|
||||
import { getAttachmentById, getAttachmentStorageConfig, getMasterAgentTask, readState } from "@/lib/boss-data";
|
||||
import { buildAttachmentDownloadHeaders } from "@/lib/boss-attachments";
|
||||
import { getAliyunOssSignedDownloadUrl } from "@/lib/boss-storage-aliyun-oss";
|
||||
import { resolveServerFileAttachmentAbsolutePath } from "@/lib/boss-storage-server-file";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
async function hasTaskTokenAccess(request: NextRequest, attachmentId: string) {
|
||||
const taskId = request.nextUrl.searchParams.get("taskId")?.trim();
|
||||
const token = request.nextUrl.searchParams.get("token")?.trim();
|
||||
if (!taskId || !token) {
|
||||
return false;
|
||||
}
|
||||
const task = await getMasterAgentTask(taskId);
|
||||
if (!task || task.taskType !== "attachment_analysis") {
|
||||
return false;
|
||||
}
|
||||
if (task.attachmentId !== attachmentId || task.attachmentDownloadToken !== token) {
|
||||
return false;
|
||||
}
|
||||
if (!task.attachmentDownloadExpiresAt) {
|
||||
return false;
|
||||
}
|
||||
return Date.parse(task.attachmentDownloadExpiresAt) > Date.now();
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ attachmentId: string }> },
|
||||
) {
|
||||
const { attachmentId } = await context.params;
|
||||
const session = await requireRequestSession(request);
|
||||
const taskTokenAccess = session ? false : await hasTaskTokenAccess(request, attachmentId);
|
||||
if (!session && !taskTokenAccess) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const record = await getAttachmentById(attachmentId);
|
||||
if (!record) {
|
||||
return NextResponse.json({ ok: false, message: "ATTACHMENT_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
if (session) {
|
||||
const state = await readState();
|
||||
if (!canSessionAccessAttachmentProject(state, session, record.project)) {
|
||||
return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
if (record.attachment.storageBackend === "aliyun_oss") {
|
||||
const aliyunConfig =
|
||||
record.attachment.storageSnapshot?.provider === "aliyun_oss"
|
||||
? {
|
||||
enabled: true,
|
||||
accessKeyId: record.attachment.storageSnapshot.accessKeyId,
|
||||
accessKeySecretEncrypted: record.attachment.storageSnapshot.accessKeySecretEncrypted,
|
||||
bucket: record.attachment.storageSnapshot.bucket,
|
||||
endpoint: record.attachment.storageSnapshot.endpoint,
|
||||
region: record.attachment.storageSnapshot.region,
|
||||
prefix: record.attachment.storageSnapshot.prefix,
|
||||
}
|
||||
: null;
|
||||
const storageConfig = aliyunConfig ? null : await getAttachmentStorageConfig(record.attachment.uploadedBy);
|
||||
const resolvedConfig =
|
||||
aliyunConfig ??
|
||||
(storageConfig?.mode === "oss" &&
|
||||
storageConfig.ossProvider === "aliyun_oss" &&
|
||||
storageConfig.aliyunOss
|
||||
? storageConfig.aliyunOss
|
||||
: null);
|
||||
if (!resolvedConfig) {
|
||||
return NextResponse.json({ ok: false, message: "ATTACHMENT_STORAGE_CONFIG_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
const signedUrl = await getAliyunOssSignedDownloadUrl(resolvedConfig, record.attachment.storagePath);
|
||||
return NextResponse.redirect(signedUrl, {
|
||||
status: 307,
|
||||
headers: buildAttachmentDownloadHeaders(record.attachment),
|
||||
});
|
||||
}
|
||||
|
||||
if (record.attachment.storageBackend !== "server_file") {
|
||||
return NextResponse.json({ ok: false, message: "UNSUPPORTED_ATTACHMENT_STORAGE_BACKEND" }, { status: 501 });
|
||||
}
|
||||
|
||||
let absolutePath: string;
|
||||
try {
|
||||
absolutePath = resolveServerFileAttachmentAbsolutePath(record.attachment.storagePath);
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, message: "ATTACHMENT_FILE_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
try {
|
||||
await stat(absolutePath);
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, message: "ATTACHMENT_FILE_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
const stream = createReadStream(absolutePath);
|
||||
return new NextResponse(Readable.toWeb(stream) as BodyInit, {
|
||||
headers: buildAttachmentDownloadHeaders(record.attachment),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { canSessionAccessAttachmentProject } from "@/lib/boss-attachment-access";
|
||||
import { getProjectAttachment, readState } from "@/lib/boss-data";
|
||||
import { queueAttachmentAnalysisTask } from "@/lib/boss-master-agent";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function resolveRequestPublicBaseUrl(request: NextRequest) {
|
||||
const protocol =
|
||||
request.headers.get("x-forwarded-proto") ?? request.nextUrl.protocol.replace(/:$/, "") ?? "http";
|
||||
const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? request.nextUrl.host;
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string; attachmentId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { projectId, attachmentId } = await context.params;
|
||||
const record = await getProjectAttachment(projectId, attachmentId);
|
||||
if (!record) {
|
||||
return NextResponse.json({ ok: false, message: "ATTACHMENT_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
if (!canSessionAccessAttachmentProject(state, session, record.project)) {
|
||||
return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (record.attachment.analysisState !== "ready_manual" && record.attachment.analysisState !== "failed") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
message: "ATTACHMENT_NOT_READY_FOR_MANUAL_ANALYSIS",
|
||||
analysisState: record.attachment.analysisState,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const task = await queueAttachmentAnalysisTask({
|
||||
projectId,
|
||||
attachmentId,
|
||||
requestMessageId: record.message.id,
|
||||
requestedBy: session.displayName || "你",
|
||||
requestedByAccount: session.account,
|
||||
markProcessing: true,
|
||||
publicBaseUrl: resolveRequestPublicBaseUrl(request),
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, taskId: task.taskId, task });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
const status = message === "ATTACHMENT_NOT_FOUND" ? 404 : 500;
|
||||
return NextResponse.json({ ok: false, message }, { status });
|
||||
}
|
||||
}
|
||||
126
src/app/api/v1/projects/[projectId]/attachments/route.ts
Normal file
126
src/app/api/v1/projects/[projectId]/attachments/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { canSessionAccessAttachmentProject } from "@/lib/boss-attachment-access";
|
||||
import {
|
||||
appendAttachmentMessage,
|
||||
getAttachmentStorageConfig,
|
||||
readState,
|
||||
type MessageAttachment,
|
||||
} from "@/lib/boss-data";
|
||||
import { queueAttachmentAnalysisTask } from "@/lib/boss-master-agent";
|
||||
import { detectAttachmentKind, resolveAttachmentAnalysisState } from "@/lib/boss-attachments";
|
||||
import { getAttachmentStorageProvider } from "@/lib/boss-storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function randomToken(prefix: string) {
|
||||
return `${prefix}-${randomBytes(4).toString("hex")}`;
|
||||
}
|
||||
|
||||
function resolveRequestPublicBaseUrl(request: NextRequest) {
|
||||
const protocol =
|
||||
request.headers.get("x-forwarded-proto") ?? request.nextUrl.protocol.replace(/:$/, "") ?? "http";
|
||||
const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? request.nextUrl.host;
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { projectId } = await context.params;
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
if (!project) {
|
||||
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
if (!canSessionAccessAttachmentProject(state, session, project)) {
|
||||
return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ ok: false, message: "FILE_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(await file.arrayBuffer());
|
||||
const attachmentId = randomToken("att");
|
||||
const messageId = randomToken("msg");
|
||||
const fileName = file.name || "attachment";
|
||||
const mimeType = file.type || "application/octet-stream";
|
||||
const attachmentKind = detectAttachmentKind(fileName, mimeType);
|
||||
const analysisState = resolveAttachmentAnalysisState(attachmentKind, bytes.byteLength);
|
||||
const storageConfig = await getAttachmentStorageConfig(session.account);
|
||||
const storageProvider = getAttachmentStorageProvider(storageConfig);
|
||||
const stored = await storageProvider.storeAttachment({
|
||||
account: session.account,
|
||||
messageId,
|
||||
attachmentId,
|
||||
fileName,
|
||||
mimeType,
|
||||
buffer: bytes,
|
||||
});
|
||||
|
||||
const attachment: MessageAttachment = {
|
||||
attachmentId,
|
||||
fileName,
|
||||
mimeType,
|
||||
fileSizeBytes: bytes.byteLength,
|
||||
attachmentKind,
|
||||
storageBackend: stored.storageBackend,
|
||||
storagePath: stored.storagePath,
|
||||
storageSnapshot:
|
||||
stored.storageBackend === "aliyun_oss" &&
|
||||
storageConfig.mode === "oss" &&
|
||||
storageConfig.ossProvider === "aliyun_oss" &&
|
||||
storageConfig.aliyunOss
|
||||
? {
|
||||
provider: "aliyun_oss",
|
||||
accessKeyId: storageConfig.aliyunOss.accessKeyId,
|
||||
accessKeySecretEncrypted: storageConfig.aliyunOss.accessKeySecretEncrypted,
|
||||
bucket: storageConfig.aliyunOss.bucket,
|
||||
endpoint: storageConfig.aliyunOss.endpoint,
|
||||
region: storageConfig.aliyunOss.region,
|
||||
prefix: storageConfig.aliyunOss.prefix,
|
||||
}
|
||||
: undefined,
|
||||
previewAvailable: attachmentKind === "image" || attachmentKind === "video" || attachmentKind === "pdf",
|
||||
uploadedAt: new Date().toISOString(),
|
||||
uploadedBy: session.account,
|
||||
analysisState,
|
||||
};
|
||||
|
||||
const message = await appendAttachmentMessage({
|
||||
projectId,
|
||||
sender: "user",
|
||||
senderLabel: session.displayName || "你",
|
||||
attachment,
|
||||
});
|
||||
|
||||
let analysisTask = null;
|
||||
if (attachment.analysisState === "queued_auto") {
|
||||
analysisTask = await queueAttachmentAnalysisTask({
|
||||
projectId,
|
||||
attachmentId,
|
||||
requestMessageId: message.id,
|
||||
requestedBy: session.displayName || "你",
|
||||
requestedByAccount: session.account,
|
||||
publicBaseUrl: resolveRequestPublicBaseUrl(request),
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
attachment,
|
||||
message,
|
||||
analysisTask,
|
||||
downloadUrl: `/api/v1/attachments/${attachmentId}/download`,
|
||||
});
|
||||
}
|
||||
53
src/app/api/v1/storage/config/route.ts
Normal file
53
src/app/api/v1/storage/config/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import {
|
||||
getAttachmentStorageConfig,
|
||||
upsertAttachmentStorageConfig,
|
||||
} from "@/lib/boss-data";
|
||||
import {
|
||||
type AttachmentStorageConfigPatch,
|
||||
applyAttachmentStorageConfigPatch,
|
||||
} from "@/lib/boss-storage-config";
|
||||
import {
|
||||
normalizeStorageError,
|
||||
sanitizeAttachmentStorageConfig,
|
||||
} from "@/lib/boss-storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getAttachmentStorageConfig(session.account);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
config: sanitizeAttachmentStorageConfig(config),
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json()) as AttachmentStorageConfigPatch;
|
||||
|
||||
try {
|
||||
const existing = await getAttachmentStorageConfig(session.account);
|
||||
const nextConfig = await applyAttachmentStorageConfigPatch(existing, body);
|
||||
const saved = await upsertAttachmentStorageConfig(nextConfig);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
config: sanitizeAttachmentStorageConfig(saved),
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: normalizeStorageError(error) },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
49
src/app/api/v1/storage/config/validate/route.ts
Normal file
49
src/app/api/v1/storage/config/validate/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import {
|
||||
getAttachmentStorageConfig,
|
||||
upsertAttachmentStorageConfig,
|
||||
} from "@/lib/boss-data";
|
||||
import {
|
||||
type AttachmentStorageConfigPatch,
|
||||
applyAttachmentStorageConfigPatch,
|
||||
} from "@/lib/boss-storage-config";
|
||||
import {
|
||||
normalizeStorageError,
|
||||
sanitizeAttachmentStorageConfig,
|
||||
validateAttachmentStorageConfig,
|
||||
} from "@/lib/boss-storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as AttachmentStorageConfigPatch;
|
||||
|
||||
try {
|
||||
const existing = await getAttachmentStorageConfig(session.account);
|
||||
const draft = await applyAttachmentStorageConfigPatch(existing, body);
|
||||
const result = await validateAttachmentStorageConfig(draft);
|
||||
const saved = await upsertAttachmentStorageConfig({
|
||||
...draft,
|
||||
validatedAt: new Date().toISOString(),
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
provider: result.provider,
|
||||
bucket: result.bucket,
|
||||
endpoint: result.endpoint,
|
||||
region: result.region,
|
||||
config: sanitizeAttachmentStorageConfig(saved),
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: normalizeStorageError(error) },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,11 @@ export default async function MePage() {
|
||||
<HeaderTitle title="我的" />
|
||||
<div className="flex flex-col gap-3 px-[18px] pb-5">
|
||||
<ProfileHero user={state.user} />
|
||||
<MenuRow
|
||||
href="/me/storage"
|
||||
title="附件与存储"
|
||||
description="当前附件存储模式、服务器文件存储与阿里 OSS"
|
||||
/>
|
||||
<MenuRow
|
||||
href="/me/security"
|
||||
title="账号与安全"
|
||||
|
||||
28
src/app/me/storage/page.tsx
Normal file
28
src/app/me/storage/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { AppShell, PageNav, StatusBar } from "@/components/app-ui";
|
||||
import { AttachmentStorageClient } from "@/components/attachment-storage-client";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { getAttachmentStorageConfig } from "@/lib/boss-data";
|
||||
import {
|
||||
sanitizeAttachmentStorageConfig,
|
||||
type SanitizedUserAttachmentStorageConfig,
|
||||
} from "@/lib/boss-storage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function getStorageConfigForSession(account: string): Promise<SanitizedUserAttachmentStorageConfig> {
|
||||
const config = await getAttachmentStorageConfig(account);
|
||||
return sanitizeAttachmentStorageConfig(config);
|
||||
}
|
||||
|
||||
export default async function StoragePage() {
|
||||
const session = await requirePageSession();
|
||||
const config = await getStorageConfigForSession(session.account);
|
||||
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<StatusBar />
|
||||
<PageNav title="附件与存储" backHref="/me" />
|
||||
<AttachmentStorageClient key={`${config.mode}:${config.updatedAt}`} config={config} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
251
src/components/attachment-storage-client.tsx
Normal file
251
src/components/attachment-storage-client.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { SanitizedUserAttachmentStorageConfig } from "@/lib/boss-storage";
|
||||
|
||||
type StorageMode = SanitizedUserAttachmentStorageConfig["mode"];
|
||||
|
||||
type StorageDraft = {
|
||||
mode: StorageMode;
|
||||
ossProvider: "aliyun_oss";
|
||||
accessKeyId: string;
|
||||
accessKeySecret: string;
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
region: string;
|
||||
prefix: string;
|
||||
};
|
||||
|
||||
function draftFromConfig(config: SanitizedUserAttachmentStorageConfig): StorageDraft {
|
||||
return {
|
||||
mode: config.mode,
|
||||
ossProvider: config.ossProvider ?? "aliyun_oss",
|
||||
accessKeyId: config.aliyunOss?.accessKeyId ?? "",
|
||||
accessKeySecret: "",
|
||||
bucket: config.aliyunOss?.bucket ?? "",
|
||||
endpoint: config.aliyunOss?.endpoint ?? "",
|
||||
region: config.aliyunOss?.region ?? "",
|
||||
prefix: config.aliyunOss?.prefix ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
type?: "text" | "password";
|
||||
}) {
|
||||
return (
|
||||
<label className="space-y-1">
|
||||
<div className="text-[12px] text-[#8C8C8C]">{label}</div>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[14px] text-[#111111] outline-none"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function AttachmentStorageClient({
|
||||
config,
|
||||
}: {
|
||||
config: SanitizedUserAttachmentStorageConfig;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [currentConfig, setCurrentConfig] = useState<SanitizedUserAttachmentStorageConfig>(config);
|
||||
const [draft, setDraft] = useState<StorageDraft>(() => draftFromConfig(config));
|
||||
const [busyKey, setBusyKey] = useState<"save" | "validate" | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
async function submit(kind: "save" | "validate") {
|
||||
setBusyKey(kind);
|
||||
|
||||
const body =
|
||||
draft.mode === "oss"
|
||||
? {
|
||||
mode: "oss" as const,
|
||||
ossProvider: "aliyun_oss" as const,
|
||||
aliyunOss: {
|
||||
accessKeyId: draft.accessKeyId,
|
||||
bucket: draft.bucket,
|
||||
endpoint: draft.endpoint,
|
||||
region: draft.region,
|
||||
prefix: draft.prefix,
|
||||
...(draft.accessKeySecret.trim() ? { accessKeySecret: draft.accessKeySecret } : {}),
|
||||
},
|
||||
}
|
||||
: {
|
||||
mode: "server_file" as const,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
kind === "validate" ? "/api/v1/storage/config/validate" : "/api/v1/storage/config",
|
||||
{
|
||||
method: kind === "validate" ? "POST" : "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
const result = (await response.json()) as {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
config?: SanitizedUserAttachmentStorageConfig;
|
||||
};
|
||||
setBusyKey(null);
|
||||
if (result.ok) {
|
||||
const nextConfig = result.config ?? currentConfig;
|
||||
setCurrentConfig(nextConfig);
|
||||
setDraft(draftFromConfig(nextConfig));
|
||||
setMessage("附件存储配置已保存。");
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
setMessage(result.message ?? "保存失败。");
|
||||
}
|
||||
|
||||
const modeLabel = currentConfig.mode === "server_file" ? "服务器文件存储" : "OSS";
|
||||
const buttonLabel =
|
||||
draft.mode === "server_file"
|
||||
? "切回服务器文件存储"
|
||||
: busyKey === "validate"
|
||||
? "测试中"
|
||||
: "测试并保存";
|
||||
|
||||
return (
|
||||
<div className="space-y-3 px-[18px] pb-6">
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[16px] font-semibold text-[#111111]">当前用户附件存储模式</div>
|
||||
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">
|
||||
当前模式:<span className="font-semibold text-[#111111]">{modeLabel}</span>
|
||||
<br />
|
||||
绑定账号:{currentConfig.account}
|
||||
<br />
|
||||
{currentConfig.mode === "oss"
|
||||
? `OSS 提供方:阿里 OSS · 密钥${currentConfig.aliyunOss?.accessKeySecretConfigured ? "已保存" : "未保存"}`
|
||||
: "附件将继续写入服务器文件存储。"}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
{(["server_file", "oss"] as const).map((mode) => {
|
||||
const active = draft.mode === mode;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => setDraft((current) => ({ ...current, mode }))}
|
||||
className={clsx(
|
||||
"rounded-full px-3 py-2 text-[12px] font-semibold",
|
||||
active ? "bg-[#07C160] text-white" : "bg-[#F5F5F7] text-[#57606A]",
|
||||
)}
|
||||
>
|
||||
{mode === "server_file" ? "服务器文件存储" : "OSS"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draft.mode === "oss" ? (
|
||||
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[16px] font-semibold text-[#111111]">阿里 OSS 配置</div>
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
当前仅支持阿里 OSS。AccessKey Secret 不会回显,留空表示沿用已保存的密钥。
|
||||
</div>
|
||||
<Field
|
||||
label="AccessKey ID"
|
||||
value={draft.accessKeyId}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, accessKeyId: value }))}
|
||||
placeholder="请输入 AccessKey ID"
|
||||
/>
|
||||
<Field
|
||||
label="AccessKey Secret"
|
||||
value={draft.accessKeySecret}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, accessKeySecret: value }))}
|
||||
placeholder="请输入 AccessKey Secret"
|
||||
type="password"
|
||||
/>
|
||||
<Field
|
||||
label="Bucket"
|
||||
value={draft.bucket}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, bucket: value }))}
|
||||
placeholder="例如 boss-attachments"
|
||||
/>
|
||||
<Field
|
||||
label="Endpoint"
|
||||
value={draft.endpoint}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, endpoint: value }))}
|
||||
placeholder="例如 oss-cn-hangzhou.aliyuncs.com"
|
||||
/>
|
||||
<Field
|
||||
label="Region"
|
||||
value={draft.region}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, region: value }))}
|
||||
placeholder="例如 oss-cn-hangzhou"
|
||||
/>
|
||||
<Field
|
||||
label="Prefix(可选)"
|
||||
value={draft.prefix}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, prefix: value }))}
|
||||
placeholder="例如 boss/"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submit("validate")}
|
||||
disabled={busyKey !== null}
|
||||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submit("save")}
|
||||
disabled={busyKey !== null}
|
||||
className="rounded-full border border-[#E5E5EA] bg-white px-4 py-2 text-[13px] font-semibold text-[#111111] disabled:opacity-60"
|
||||
>
|
||||
{busyKey === "save" ? "保存中" : "仅保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[16px] font-semibold text-[#111111]">服务器文件存储</div>
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
切回后附件继续保存在服务器本地文件存储,不会走 OSS 配置校验。
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submit("save")}
|
||||
disabled={busyKey !== null}
|
||||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{busyKey === "save" ? "保存中" : buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message ? (
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{draft.mode === "oss" ? (
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
保存前请确认 Bucket、Endpoint 和 Region 都完整可用,测试通过后会同步写回当前账号配置。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/lib/boss-attachment-access.ts
Normal file
38
src/lib/boss-attachment-access.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { AuthSession, BossState, Project } from "@/lib/boss-data";
|
||||
|
||||
function getAccountOwnedDeviceIds(state: BossState, account: string) {
|
||||
return new Set(
|
||||
state.devices
|
||||
.filter((device) => device.account === account)
|
||||
.map((device) => device.id),
|
||||
);
|
||||
}
|
||||
|
||||
export function canSessionAccessAttachmentProject(
|
||||
state: BossState,
|
||||
session: Pick<AuthSession, "account" | "role">,
|
||||
project: Pick<Project, "deviceIds" | "groupMembers">,
|
||||
) {
|
||||
if (session.role === "highest_admin") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ownedDeviceIds = getAccountOwnedDeviceIds(state, session.account);
|
||||
if (ownedDeviceIds.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const deviceId of project.deviceIds) {
|
||||
if (ownedDeviceIds.has(deviceId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const member of project.groupMembers) {
|
||||
if (ownedDeviceIds.has(member.deviceId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
105
src/lib/boss-attachments.ts
Normal file
105
src/lib/boss-attachments.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import path from "node:path";
|
||||
import type {
|
||||
AttachmentAnalysisState,
|
||||
AttachmentKind,
|
||||
MessageAttachment,
|
||||
} from "@/lib/boss-data";
|
||||
|
||||
const LARGE_ATTACHMENT_THRESHOLD_BYTES = 20 * 1024 * 1024;
|
||||
const MAX_ATTACHMENT_TEXT_EXCERPT_CHARS = 12_000;
|
||||
const MAX_ATTACHMENT_TEXT_EXCERPT_SOURCE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function extensionOf(fileName: string) {
|
||||
return path.extname(fileName).toLowerCase();
|
||||
}
|
||||
|
||||
export function sanitizeFileName(fileName: string) {
|
||||
const normalized = path
|
||||
.basename(fileName || "attachment")
|
||||
.normalize("NFKC")
|
||||
.replace(/[\u0000-\u001f\u007f]/g, "")
|
||||
.replace(/[<>:"|?*]+/g, "-")
|
||||
.replace(/[\\/]+/g, "-")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/^\.+/, "");
|
||||
return normalized || "attachment";
|
||||
}
|
||||
|
||||
export function detectAttachmentKind(fileName: string, mimeType: string): AttachmentKind {
|
||||
const normalizedMime = (mimeType || "").toLowerCase();
|
||||
const ext = extensionOf(fileName);
|
||||
|
||||
if (normalizedMime.startsWith("image/")) return "image";
|
||||
if (normalizedMime.startsWith("video/")) return "video";
|
||||
if (normalizedMime === "application/pdf" || ext === ".pdf") return "pdf";
|
||||
if (normalizedMime.startsWith("text/")) return "text";
|
||||
if (
|
||||
normalizedMime.includes("officedocument") ||
|
||||
normalizedMime.includes("msword") ||
|
||||
normalizedMime.includes("spreadsheet") ||
|
||||
normalizedMime.includes("presentation") ||
|
||||
[".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp"].includes(ext)
|
||||
) {
|
||||
return "office";
|
||||
}
|
||||
if (
|
||||
[".txt", ".md", ".csv", ".log", ".json", ".yaml", ".yml", ".xml", ".html", ".htm"].includes(
|
||||
ext,
|
||||
)
|
||||
) {
|
||||
return "text";
|
||||
}
|
||||
return "binary";
|
||||
}
|
||||
|
||||
export function resolveAttachmentAnalysisState(
|
||||
kind: AttachmentKind,
|
||||
fileSizeBytes: number,
|
||||
): AttachmentAnalysisState {
|
||||
if (fileSizeBytes > LARGE_ATTACHMENT_THRESHOLD_BYTES) {
|
||||
return "ready_manual";
|
||||
}
|
||||
if (kind === "image" || kind === "pdf" || kind === "text") {
|
||||
return "queued_auto";
|
||||
}
|
||||
return "ready_manual";
|
||||
}
|
||||
|
||||
export function buildAttachmentDownloadHeaders(attachment: MessageAttachment) {
|
||||
const safeName = sanitizeFileName(attachment.fileName);
|
||||
const encodedName = encodeURIComponent(safeName);
|
||||
return {
|
||||
"Content-Type": attachment.mimeType || "application/octet-stream",
|
||||
"Content-Disposition": `inline; filename="${safeName}"; filename*=UTF-8''${encodedName}`,
|
||||
"Cache-Control": "private, no-store, max-age=0",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
};
|
||||
}
|
||||
|
||||
export function canExtractAttachmentText(attachment: Pick<MessageAttachment, "attachmentKind" | "mimeType">) {
|
||||
return (
|
||||
attachment.attachmentKind === "text" ||
|
||||
(attachment.mimeType || "").toLowerCase().startsWith("text/")
|
||||
);
|
||||
}
|
||||
|
||||
export function canInlineAttachmentText(
|
||||
attachment: Pick<MessageAttachment, "attachmentKind" | "mimeType" | "fileSizeBytes">,
|
||||
) {
|
||||
return canExtractAttachmentText(attachment) && attachment.fileSizeBytes <= MAX_ATTACHMENT_TEXT_EXCERPT_SOURCE_BYTES;
|
||||
}
|
||||
|
||||
export function extractAttachmentTextExcerpt(buffer: Buffer | Uint8Array) {
|
||||
const normalized = Buffer.from(buffer)
|
||||
.toString("utf8")
|
||||
.replace(/\u0000/g, "")
|
||||
.trim();
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
if (normalized.length <= MAX_ATTACHMENT_TEXT_EXCERPT_CHARS) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, MAX_ATTACHMENT_TEXT_EXCERPT_CHARS)}\n...[已截断]`;
|
||||
}
|
||||
@@ -20,7 +20,43 @@ export type MessageKind =
|
||||
| "video_intent"
|
||||
| "forward_notice"
|
||||
| "forward_single"
|
||||
| "forward_bundle";
|
||||
| "forward_bundle"
|
||||
| "attachment"
|
||||
| "analysis_card";
|
||||
export type AttachmentKind = "image" | "video" | "pdf" | "text" | "office" | "binary";
|
||||
export type AttachmentStorageBackend = "server_file" | "aliyun_oss";
|
||||
export type AttachmentAnalysisState =
|
||||
| "not_applicable"
|
||||
| "queued_auto"
|
||||
| "ready_manual"
|
||||
| "processing"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
export interface MessageAttachment {
|
||||
attachmentId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSizeBytes: number;
|
||||
attachmentKind: AttachmentKind;
|
||||
storageBackend: AttachmentStorageBackend;
|
||||
storagePath: string;
|
||||
storageSnapshot?: {
|
||||
provider: "aliyun_oss";
|
||||
accessKeyId: string;
|
||||
accessKeySecretEncrypted: string;
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
region: string;
|
||||
prefix?: string;
|
||||
};
|
||||
previewAvailable: boolean;
|
||||
uploadedAt: string;
|
||||
uploadedBy: string;
|
||||
analysisState: AttachmentAnalysisState;
|
||||
analysisSummary?: string;
|
||||
analysisCardId?: string;
|
||||
}
|
||||
|
||||
export interface ForwardSource {
|
||||
sourceProjectId: string;
|
||||
@@ -94,6 +130,7 @@ export type AiProvider = "master_codex_node" | "openai_api";
|
||||
export type AiAccountRole = "primary" | "backup" | "api_fallback";
|
||||
export type AiAccountStatus = "ready" | "needs_login" | "needs_api_key" | "degraded" | "disabled";
|
||||
export type MasterAgentTaskStatus = "queued" | "running" | "completed" | "failed";
|
||||
export type MasterAgentTaskType = "conversation_reply" | "attachment_analysis";
|
||||
|
||||
export interface UserSettings {
|
||||
liveUpdates: boolean;
|
||||
@@ -146,10 +183,28 @@ export interface Message {
|
||||
body: string;
|
||||
sentAt: string;
|
||||
kind?: MessageKind;
|
||||
attachments?: MessageAttachment[];
|
||||
forwardSource?: ForwardSource;
|
||||
forwardBundle?: ForwardBundlePayload;
|
||||
}
|
||||
|
||||
export interface UserAttachmentStorageConfig {
|
||||
account: string;
|
||||
mode: "server_file" | "oss";
|
||||
ossProvider?: "aliyun_oss";
|
||||
aliyunOss?: {
|
||||
enabled: boolean;
|
||||
accessKeyId: string;
|
||||
accessKeySecretEncrypted: string;
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
region: string;
|
||||
prefix?: string;
|
||||
};
|
||||
updatedAt: string;
|
||||
validatedAt?: string;
|
||||
}
|
||||
|
||||
export interface GoalItem {
|
||||
id: string;
|
||||
text: string;
|
||||
@@ -348,6 +403,7 @@ export interface MasterIdentitySummary {
|
||||
export interface MasterAgentTask {
|
||||
taskId: string;
|
||||
projectId: string;
|
||||
taskType: MasterAgentTaskType;
|
||||
requestMessageId: string;
|
||||
requestText: string;
|
||||
executionPrompt: string;
|
||||
@@ -356,6 +412,12 @@ export interface MasterAgentTask {
|
||||
deviceId: string;
|
||||
accountId?: string;
|
||||
accountLabel?: string;
|
||||
attachmentId?: string;
|
||||
attachmentFileName?: string;
|
||||
attachmentDownloadToken?: string;
|
||||
attachmentDownloadExpiresAt?: string;
|
||||
attachmentDownloadUrl?: string;
|
||||
attachmentTextExcerpt?: string;
|
||||
status: MasterAgentTaskStatus;
|
||||
requestedAt: string;
|
||||
claimedAt?: string;
|
||||
@@ -609,6 +671,7 @@ export interface BossState {
|
||||
otaUpdateLogs: OtaUpdateLog[];
|
||||
deviceSkills: DeviceSkill[];
|
||||
appLogs: AppLogEntry[];
|
||||
userAttachmentStorageConfigs: UserAttachmentStorageConfig[];
|
||||
threadContextSnapshots: ThreadContextSnapshot[];
|
||||
threadHandoffPackages: ThreadHandoffPackage[];
|
||||
threadContextAlerts: ThreadContextAlert[];
|
||||
@@ -1016,6 +1079,13 @@ const initialState: BossState = {
|
||||
reason: "初始化默认主控身份",
|
||||
},
|
||||
],
|
||||
userAttachmentStorageConfigs: [
|
||||
{
|
||||
account: PRIMARY_ADMIN_ACCOUNT,
|
||||
mode: "server_file",
|
||||
updatedAt: nowIso(),
|
||||
},
|
||||
],
|
||||
masterAgentTasks: [],
|
||||
otaUpdates: [
|
||||
{
|
||||
@@ -1842,11 +1912,76 @@ function normalizeMessage(raw: Partial<Message>): Message {
|
||||
body: raw.body ?? "",
|
||||
sentAt: raw.sentAt ?? nowIso(),
|
||||
kind: raw.kind ?? "text",
|
||||
attachments: Array.isArray(raw.attachments)
|
||||
? raw.attachments.map((attachment) => normalizeMessageAttachment(attachment))
|
||||
: undefined,
|
||||
forwardSource: raw.forwardSource,
|
||||
forwardBundle: raw.forwardBundle,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMessageAttachment(raw: Partial<MessageAttachment>): MessageAttachment {
|
||||
return {
|
||||
attachmentId: raw.attachmentId ?? randomToken("att"),
|
||||
fileName: raw.fileName ?? "",
|
||||
mimeType: raw.mimeType ?? "application/octet-stream",
|
||||
fileSizeBytes: raw.fileSizeBytes ?? 0,
|
||||
attachmentKind: raw.attachmentKind ?? "binary",
|
||||
storageBackend: raw.storageBackend ?? "server_file",
|
||||
storagePath: raw.storagePath ?? "",
|
||||
storageSnapshot:
|
||||
raw.storageSnapshot?.provider === "aliyun_oss"
|
||||
? {
|
||||
provider: "aliyun_oss",
|
||||
accessKeyId: raw.storageSnapshot.accessKeyId ?? "",
|
||||
accessKeySecretEncrypted: raw.storageSnapshot.accessKeySecretEncrypted ?? "",
|
||||
bucket: raw.storageSnapshot.bucket ?? "",
|
||||
endpoint: raw.storageSnapshot.endpoint ?? "",
|
||||
region: raw.storageSnapshot.region ?? "",
|
||||
prefix: raw.storageSnapshot.prefix,
|
||||
}
|
||||
: undefined,
|
||||
previewAvailable: raw.previewAvailable ?? false,
|
||||
uploadedAt: raw.uploadedAt ?? nowIso(),
|
||||
uploadedBy: raw.uploadedBy ?? "system",
|
||||
analysisState: raw.analysisState ?? "not_applicable",
|
||||
analysisSummary: raw.analysisSummary,
|
||||
analysisCardId: raw.analysisCardId,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAttachmentMessageBody(attachment: MessageAttachment) {
|
||||
const sizeKb = Math.max(1, Math.round(attachment.fileSizeBytes / 1024));
|
||||
return `已发送附件:${attachment.fileName}(${attachment.attachmentKind},${sizeKb} KB)`;
|
||||
}
|
||||
|
||||
function normalizeAttachmentStorageConfig(
|
||||
raw: Partial<UserAttachmentStorageConfig>,
|
||||
fallback: UserAttachmentStorageConfig,
|
||||
): UserAttachmentStorageConfig {
|
||||
return {
|
||||
account: raw.account ?? fallback.account,
|
||||
mode: raw.mode ?? fallback.mode,
|
||||
ossProvider: raw.ossProvider ?? fallback.ossProvider,
|
||||
aliyunOss: raw.aliyunOss
|
||||
? {
|
||||
enabled: raw.aliyunOss.enabled ?? fallback.aliyunOss?.enabled ?? false,
|
||||
accessKeyId: raw.aliyunOss.accessKeyId ?? fallback.aliyunOss?.accessKeyId ?? "",
|
||||
accessKeySecretEncrypted:
|
||||
raw.aliyunOss.accessKeySecretEncrypted ??
|
||||
fallback.aliyunOss?.accessKeySecretEncrypted ??
|
||||
"",
|
||||
bucket: raw.aliyunOss.bucket ?? fallback.aliyunOss?.bucket ?? "",
|
||||
endpoint: raw.aliyunOss.endpoint ?? fallback.aliyunOss?.endpoint ?? "",
|
||||
region: raw.aliyunOss.region ?? fallback.aliyunOss?.region ?? "",
|
||||
prefix: raw.aliyunOss.prefix ?? fallback.aliyunOss?.prefix,
|
||||
}
|
||||
: fallback.aliyunOss,
|
||||
updatedAt: raw.updatedAt ?? fallback.updatedAt,
|
||||
validatedAt: raw.validatedAt ?? fallback.validatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProject(raw: Partial<Project>, fallback?: Project): Project {
|
||||
const base = fallback ?? cloneInitialState().projects[0];
|
||||
const projectId = raw.id ?? base.id;
|
||||
@@ -1998,6 +2133,7 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
|
||||
masterAgentTasks: ensureArray(raw.masterAgentTasks, base.masterAgentTasks).map((task) => ({
|
||||
taskId: task.taskId ?? randomToken("mastertask"),
|
||||
projectId: task.projectId ?? "master-agent",
|
||||
taskType: task.taskType ?? "conversation_reply",
|
||||
requestMessageId: task.requestMessageId ?? "",
|
||||
requestText: task.requestText ?? "",
|
||||
executionPrompt: task.executionPrompt ?? task.requestText ?? "",
|
||||
@@ -2006,6 +2142,12 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
|
||||
deviceId: task.deviceId ?? PRIMARY_CODEX_NODE_ID,
|
||||
accountId: task.accountId,
|
||||
accountLabel: task.accountLabel,
|
||||
attachmentId: task.attachmentId,
|
||||
attachmentFileName: task.attachmentFileName,
|
||||
attachmentDownloadToken: task.attachmentDownloadToken,
|
||||
attachmentDownloadExpiresAt: task.attachmentDownloadExpiresAt,
|
||||
attachmentDownloadUrl: task.attachmentDownloadUrl,
|
||||
attachmentTextExcerpt: task.attachmentTextExcerpt,
|
||||
status: task.status ?? "queued",
|
||||
requestedAt: task.requestedAt ?? nowIso(),
|
||||
claimedAt: task.claimedAt,
|
||||
@@ -2034,6 +2176,15 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
|
||||
mirroredToProject: log.mirroredToProject ?? false,
|
||||
createdAt: log.createdAt ?? nowIso(),
|
||||
})),
|
||||
userAttachmentStorageConfigs: ensureArray(
|
||||
raw.userAttachmentStorageConfigs,
|
||||
base.userAttachmentStorageConfigs,
|
||||
).map((config, index) =>
|
||||
normalizeAttachmentStorageConfig(
|
||||
config,
|
||||
base.userAttachmentStorageConfigs[index % base.userAttachmentStorageConfigs.length],
|
||||
),
|
||||
),
|
||||
threadContextSnapshots: ensureArray(raw.threadContextSnapshots, base.threadContextSnapshots).map(
|
||||
(snapshot, index) => ({
|
||||
...base.threadContextSnapshots[index % base.threadContextSnapshots.length],
|
||||
@@ -2580,7 +2731,6 @@ export async function readState(): Promise<BossState> {
|
||||
(await fs.readFile(backupFile, "utf8").catch(() => null)) ??
|
||||
lastPersistedStateText ??
|
||||
JSON.stringify(syncDerivedState(cloneInitialState()), null, 2);
|
||||
await fs.writeFile(dataFile, fallbackText, "utf8");
|
||||
const state = normalizeState(JSON.parse(fallbackText) as Partial<BossState>);
|
||||
lastPersistedStateText = JSON.stringify(state, null, 2);
|
||||
return state;
|
||||
@@ -2627,6 +2777,31 @@ export async function getDevice(deviceId: string) {
|
||||
return state.devices.find((device) => device.id === deviceId) ?? null;
|
||||
}
|
||||
|
||||
export async function getAttachmentStorageConfig(account: string) {
|
||||
const state = await readState();
|
||||
return (
|
||||
state.userAttachmentStorageConfigs.find((item) => item.account === account) ?? {
|
||||
account,
|
||||
mode: "server_file" as const,
|
||||
updatedAt: nowIso(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function upsertAttachmentStorageConfig(config: UserAttachmentStorageConfig) {
|
||||
return mutateState((state) => {
|
||||
const index = state.userAttachmentStorageConfigs.findIndex(
|
||||
(item) => item.account === config.account,
|
||||
);
|
||||
if (index >= 0) {
|
||||
state.userAttachmentStorageConfigs[index] = config;
|
||||
} else {
|
||||
state.userAttachmentStorageConfigs.push(config);
|
||||
}
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
function preferredDeviceForAccount(
|
||||
state: BossState,
|
||||
account: string,
|
||||
@@ -3290,6 +3465,9 @@ export async function getMasterAgentRuntimeAccount() {
|
||||
}
|
||||
|
||||
export async function queueMasterAgentTask(payload: {
|
||||
taskId?: string;
|
||||
projectId?: string;
|
||||
taskType?: MasterAgentTaskType;
|
||||
requestMessageId: string;
|
||||
requestText: string;
|
||||
executionPrompt: string;
|
||||
@@ -3298,11 +3476,18 @@ export async function queueMasterAgentTask(payload: {
|
||||
deviceId: string;
|
||||
accountId?: string;
|
||||
accountLabel?: string;
|
||||
attachmentId?: string;
|
||||
attachmentFileName?: string;
|
||||
attachmentDownloadToken?: string;
|
||||
attachmentDownloadExpiresAt?: string;
|
||||
attachmentDownloadUrl?: string;
|
||||
attachmentTextExcerpt?: string;
|
||||
}) {
|
||||
const task = await mutateState((state) => {
|
||||
const task: MasterAgentTask = {
|
||||
taskId: randomToken("mastertask"),
|
||||
projectId: "master-agent",
|
||||
taskId: payload.taskId ?? randomToken("mastertask"),
|
||||
projectId: payload.projectId ?? "master-agent",
|
||||
taskType: payload.taskType ?? "conversation_reply",
|
||||
requestMessageId: payload.requestMessageId,
|
||||
requestText: payload.requestText,
|
||||
executionPrompt: payload.executionPrompt,
|
||||
@@ -3311,6 +3496,12 @@ export async function queueMasterAgentTask(payload: {
|
||||
deviceId: payload.deviceId,
|
||||
accountId: payload.accountId,
|
||||
accountLabel: payload.accountLabel,
|
||||
attachmentId: payload.attachmentId,
|
||||
attachmentFileName: payload.attachmentFileName,
|
||||
attachmentDownloadToken: payload.attachmentDownloadToken,
|
||||
attachmentDownloadExpiresAt: payload.attachmentDownloadExpiresAt,
|
||||
attachmentDownloadUrl: payload.attachmentDownloadUrl,
|
||||
attachmentTextExcerpt: payload.attachmentTextExcerpt,
|
||||
status: "queued",
|
||||
requestedAt: nowIso(),
|
||||
};
|
||||
@@ -3331,6 +3522,7 @@ export async function getMasterAgentTask(taskId: string) {
|
||||
}
|
||||
|
||||
export async function claimNextMasterAgentTask(deviceId: string) {
|
||||
let attachmentProjectId: string | undefined;
|
||||
const task = await mutateState((state) => {
|
||||
const next = state.masterAgentTasks.find(
|
||||
(item) => item.deviceId === deviceId && item.status === "queued",
|
||||
@@ -3338,6 +3530,16 @@ export async function claimNextMasterAgentTask(deviceId: string) {
|
||||
if (!next) return null;
|
||||
next.status = "running";
|
||||
next.claimedAt = nowIso();
|
||||
if (next.taskType === "attachment_analysis" && next.attachmentId) {
|
||||
const project = state.projects.find((item) => item.id === next.projectId);
|
||||
const match = project ? findProjectAttachment(project, next.attachmentId) : null;
|
||||
if (match) {
|
||||
match.attachment.analysisState = "processing";
|
||||
match.attachment.analysisSummary = undefined;
|
||||
match.attachment.analysisCardId = undefined;
|
||||
attachmentProjectId = next.projectId;
|
||||
}
|
||||
}
|
||||
return { ...next };
|
||||
});
|
||||
if (task) {
|
||||
@@ -3346,6 +3548,10 @@ export async function claimNextMasterAgentTask(deviceId: string) {
|
||||
deviceId: task.deviceId,
|
||||
status: task.status,
|
||||
});
|
||||
if (attachmentProjectId) {
|
||||
publishBossEvent("project.messages.updated", { projectId: attachmentProjectId });
|
||||
publishBossEvent("conversation.updated", { projectId: attachmentProjectId });
|
||||
}
|
||||
}
|
||||
return task;
|
||||
}
|
||||
@@ -3392,15 +3598,56 @@ export async function completeMasterAgentTask(payload: {
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.status === "completed" && task.replyBody) {
|
||||
pushProjectLedgerMessage(state, "master-agent", {
|
||||
let attachmentProjectId: string | undefined;
|
||||
if (task.taskType === "attachment_analysis" && task.attachmentId) {
|
||||
const project = state.projects.find((item) => item.id === task.projectId);
|
||||
const match = project ? findProjectAttachment(project, task.attachmentId) : null;
|
||||
if (match) {
|
||||
attachmentProjectId = project?.id;
|
||||
if (payload.status === "completed") {
|
||||
const summary = summarizeAttachmentAnalysis(task.replyBody ?? "");
|
||||
match.attachment.analysisState = "completed";
|
||||
match.attachment.analysisSummary = summary;
|
||||
pushProjectLedgerMessage(state, task.projectId, {
|
||||
sender: "master",
|
||||
senderLabel: task.accountLabel ? `主 Agent · ${task.accountLabel}` : "主 Agent",
|
||||
body: summary,
|
||||
kind: "text",
|
||||
});
|
||||
if (task.replyBody) {
|
||||
const card = pushProjectLedgerMessage(state, task.projectId, {
|
||||
sender: "master",
|
||||
senderLabel: task.accountLabel ? `主 Agent · ${task.accountLabel}` : "主 Agent",
|
||||
body: task.replyBody,
|
||||
kind: "analysis_card",
|
||||
});
|
||||
match.attachment.analysisCardId = card?.id;
|
||||
} else {
|
||||
match.attachment.analysisCardId = undefined;
|
||||
}
|
||||
} else if (payload.status === "failed") {
|
||||
match.attachment.analysisState = "failed";
|
||||
match.attachment.analysisSummary = task.errorMessage ?? "附件分析失败,请稍后重试。";
|
||||
match.attachment.analysisCardId = undefined;
|
||||
pushProjectLedgerMessage(state, task.projectId, {
|
||||
sender: "ops",
|
||||
senderLabel: task.accountLabel ? `主 Agent Relay · ${task.accountLabel}` : "主 Agent Relay",
|
||||
body: `附件分析失败:${task.errorMessage ?? "UNKNOWN_ERROR"}`,
|
||||
kind: "text",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!attachmentProjectId && payload.status === "completed" && task.replyBody) {
|
||||
pushProjectLedgerMessage(state, task.projectId, {
|
||||
sender: "master",
|
||||
senderLabel: task.accountLabel ? `主 Agent · ${task.accountLabel}` : "主 Agent",
|
||||
body: task.replyBody,
|
||||
kind: "text",
|
||||
});
|
||||
} else if (payload.status === "failed") {
|
||||
pushProjectLedgerMessage(state, "master-agent", {
|
||||
} else if (!attachmentProjectId && payload.status === "failed") {
|
||||
pushProjectLedgerMessage(state, task.projectId, {
|
||||
sender: "ops",
|
||||
senderLabel: task.accountLabel ? `主 Agent Relay · ${task.accountLabel}` : "主 Agent Relay",
|
||||
body: `Master Codex Node 执行失败:${task.errorMessage ?? "UNKNOWN_ERROR"}`,
|
||||
@@ -3416,8 +3663,8 @@ export async function completeMasterAgentTask(payload: {
|
||||
deviceId: result.deviceId,
|
||||
status: result.status,
|
||||
});
|
||||
publishBossEvent("project.messages.updated", { projectId: "master-agent" });
|
||||
publishBossEvent("conversation.updated", { projectId: "master-agent" });
|
||||
publishBossEvent("project.messages.updated", { projectId: result.projectId });
|
||||
publishBossEvent("conversation.updated", { projectId: result.projectId });
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -4248,6 +4495,7 @@ export async function appendProjectMessage(payload: {
|
||||
senderLabel?: string;
|
||||
body?: string;
|
||||
kind?: MessageKind;
|
||||
attachments?: MessageAttachment[];
|
||||
}) {
|
||||
const message = await mutateState((state) => {
|
||||
const project = state.projects.find((item) => item.id === payload.projectId);
|
||||
@@ -4257,22 +4505,43 @@ export async function appendProjectMessage(payload: {
|
||||
if (!body && payload.kind === "text") {
|
||||
throw new Error("MESSAGE_BODY_REQUIRED");
|
||||
}
|
||||
if (payload.kind === "attachment" && (!payload.attachments || payload.attachments.length === 0)) {
|
||||
throw new Error("ATTACHMENT_REQUIRED");
|
||||
}
|
||||
|
||||
const firstAttachment = payload.attachments?.[0];
|
||||
const message: Message = {
|
||||
id: randomToken("msg"),
|
||||
sender: payload.sender ?? "user",
|
||||
senderLabel: payload.senderLabel ?? "你",
|
||||
body:
|
||||
body ??
|
||||
(payload.kind === "voice_intent"
|
||||
? "已提交语音转文字请求,等待主 Agent 记录语音摘要。"
|
||||
: payload.kind === "image_intent"
|
||||
? "已登记图片证据上传请求,等待对象存储通道接入。"
|
||||
: payload.kind === "video_intent"
|
||||
? "已登记视频证据上传请求,等待对象存储通道接入。"
|
||||
: "已提交消息。"),
|
||||
(payload.kind === "attachment"
|
||||
? buildAttachmentMessageBody(
|
||||
firstAttachment ?? {
|
||||
attachmentId: randomToken("att"),
|
||||
fileName: "附件",
|
||||
mimeType: "application/octet-stream",
|
||||
fileSizeBytes: 0,
|
||||
attachmentKind: "binary",
|
||||
storageBackend: "server_file",
|
||||
storagePath: "",
|
||||
previewAvailable: false,
|
||||
uploadedAt: nowIso(),
|
||||
uploadedBy: payload.senderLabel ?? "你",
|
||||
analysisState: "not_applicable",
|
||||
},
|
||||
)
|
||||
: payload.kind === "voice_intent"
|
||||
? "已提交语音转文字请求,等待主 Agent 记录语音摘要。"
|
||||
: payload.kind === "image_intent"
|
||||
? "已登记图片证据上传请求,等待对象存储通道接入。"
|
||||
: payload.kind === "video_intent"
|
||||
? "已登记视频证据上传请求,等待对象存储通道接入。"
|
||||
: "已提交消息。"),
|
||||
sentAt: nowIso(),
|
||||
kind: payload.kind ?? "text",
|
||||
attachments: payload.attachments?.map((attachment) => normalizeMessageAttachment(attachment)),
|
||||
};
|
||||
|
||||
project.messages.push(message);
|
||||
@@ -4287,10 +4556,136 @@ export async function appendProjectMessage(payload: {
|
||||
return message;
|
||||
}
|
||||
|
||||
export async function appendAttachmentMessage(payload: {
|
||||
projectId: string;
|
||||
sender?: MessageSender;
|
||||
senderLabel?: string;
|
||||
attachment: MessageAttachment;
|
||||
body?: string;
|
||||
}) {
|
||||
return appendProjectMessage({
|
||||
projectId: payload.projectId,
|
||||
sender: payload.sender ?? "user",
|
||||
senderLabel: payload.senderLabel ?? "你",
|
||||
body: payload.body ?? buildAttachmentMessageBody(payload.attachment),
|
||||
kind: "attachment",
|
||||
attachments: [payload.attachment],
|
||||
});
|
||||
}
|
||||
|
||||
function findProjectMessage(project: Project, messageId: string) {
|
||||
return project.messages.find((message) => message.id === messageId) ?? null;
|
||||
}
|
||||
|
||||
export function findProjectAttachment(
|
||||
project: Project,
|
||||
attachmentId: string,
|
||||
): { message: Message; attachment: MessageAttachment } | null {
|
||||
for (const message of project.messages) {
|
||||
const attachment = message.attachments?.find((item) => item.attachmentId === attachmentId);
|
||||
if (attachment) {
|
||||
return { message, attachment };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getProjectAttachment(projectId: string, attachmentId: string) {
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
if (!project) {
|
||||
return null;
|
||||
}
|
||||
const match = findProjectAttachment(project, attachmentId);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
project,
|
||||
message: match.message,
|
||||
attachment: match.attachment,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAttachmentById(attachmentId: string) {
|
||||
const state = await readState();
|
||||
for (const project of state.projects) {
|
||||
const match = findProjectAttachment(project, attachmentId);
|
||||
if (match) {
|
||||
return {
|
||||
project,
|
||||
message: match.message,
|
||||
attachment: match.attachment,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function summarizeAttachmentAnalysis(body: string) {
|
||||
const compact = body.replace(/\s+/g, " ").trim();
|
||||
if (!compact) {
|
||||
return "附件分析已完成。";
|
||||
}
|
||||
return compact.length <= 120 ? compact : `${compact.slice(0, 117)}...`;
|
||||
}
|
||||
|
||||
export async function updateAttachmentAnalysisResult(payload: {
|
||||
projectId: string;
|
||||
attachmentId: string;
|
||||
status: Exclude<AttachmentAnalysisState, "not_applicable" | "queued_auto" | "ready_manual">;
|
||||
summary?: string;
|
||||
cardBody?: string;
|
||||
}) {
|
||||
return mutateState((state) => {
|
||||
const project = state.projects.find((item) => item.id === payload.projectId);
|
||||
if (!project) {
|
||||
throw new Error("PROJECT_NOT_FOUND");
|
||||
}
|
||||
const match = findProjectAttachment(project, payload.attachmentId);
|
||||
if (!match) {
|
||||
throw new Error("ATTACHMENT_NOT_FOUND");
|
||||
}
|
||||
|
||||
match.attachment.analysisState = payload.status;
|
||||
match.attachment.analysisSummary =
|
||||
payload.status === "completed"
|
||||
? payload.summary?.trim() || summarizeAttachmentAnalysis(payload.cardBody ?? "")
|
||||
: payload.summary;
|
||||
match.attachment.analysisCardId = undefined;
|
||||
|
||||
if (payload.status === "completed" && payload.cardBody?.trim()) {
|
||||
const summary = payload.summary?.trim() || summarizeAttachmentAnalysis(payload.cardBody);
|
||||
pushProjectLedgerMessage(state, payload.projectId, {
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: summary,
|
||||
kind: "text",
|
||||
});
|
||||
const card = pushProjectLedgerMessage(state, payload.projectId, {
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: payload.cardBody.trim(),
|
||||
kind: "analysis_card",
|
||||
});
|
||||
match.attachment.analysisCardId = card?.id;
|
||||
match.attachment.analysisSummary = summary;
|
||||
}
|
||||
|
||||
return {
|
||||
projectId: payload.projectId,
|
||||
attachmentId: payload.attachmentId,
|
||||
analysisState: match.attachment.analysisState,
|
||||
analysisSummary: match.attachment.analysisSummary,
|
||||
analysisCardId: match.attachment.analysisCardId,
|
||||
};
|
||||
}).then((result) => {
|
||||
publishBossEvent("project.messages.updated", { projectId: result.projectId });
|
||||
publishBossEvent("conversation.updated", { projectId: result.projectId });
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function requiresForwardApproval(source: Project, target: Project) {
|
||||
return source.collaborationMode === "approval_required" && target.id !== "master-agent";
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
AUTH_SESSION_TTL_MS,
|
||||
aiProviderLabel,
|
||||
appendProjectMessage,
|
||||
getProjectAttachment,
|
||||
getAttachmentStorageConfig,
|
||||
getRuntimeAiAccountById,
|
||||
getMasterAgentRuntimeAccount,
|
||||
getMasterAgentTask,
|
||||
queueMasterAgentTask,
|
||||
readState,
|
||||
updateAttachmentAnalysisResult,
|
||||
updateAiAccountHealth,
|
||||
} from "@/lib/boss-data";
|
||||
import { canInlineAttachmentText, extractAttachmentTextExcerpt } from "@/lib/boss-attachments";
|
||||
import { readAliyunOssObjectBuffer } from "@/lib/boss-storage-aliyun-oss";
|
||||
import { readServerFileAttachmentBuffer } from "@/lib/boss-storage-server-file";
|
||||
|
||||
function buildMasterAgentInstructions() {
|
||||
return [
|
||||
@@ -218,6 +225,166 @@ async function waitForMasterAgentTaskCompletion(taskId: string, timeoutMs = 55_0
|
||||
return getMasterAgentTask(taskId);
|
||||
}
|
||||
|
||||
function resolveBossPublicBaseUrl() {
|
||||
const configured = process.env.BOSS_PUBLIC_BASE_URL?.trim();
|
||||
return configured && /^https?:\/\//i.test(configured) ? configured.replace(/\/+$/, "") : "https://boss.hyzq.net";
|
||||
}
|
||||
|
||||
async function buildAttachmentAnalysisContext(params: {
|
||||
attachment: NonNullable<Awaited<ReturnType<typeof getProjectAttachment>>>["attachment"];
|
||||
}) {
|
||||
const attachment = params.attachment;
|
||||
let excerpt = "";
|
||||
try {
|
||||
if (canInlineAttachmentText(attachment)) {
|
||||
let buffer: Buffer | Uint8Array = Buffer.alloc(0);
|
||||
if (attachment.storageBackend === "server_file") {
|
||||
buffer = await readServerFileAttachmentBuffer(attachment.storagePath);
|
||||
} else if (attachment.storageBackend === "aliyun_oss") {
|
||||
if (attachment.storageSnapshot?.provider === "aliyun_oss") {
|
||||
buffer = await readAliyunOssObjectBuffer(
|
||||
{
|
||||
enabled: true,
|
||||
accessKeyId: attachment.storageSnapshot.accessKeyId,
|
||||
accessKeySecretEncrypted: attachment.storageSnapshot.accessKeySecretEncrypted,
|
||||
bucket: attachment.storageSnapshot.bucket,
|
||||
endpoint: attachment.storageSnapshot.endpoint,
|
||||
region: attachment.storageSnapshot.region,
|
||||
prefix: attachment.storageSnapshot.prefix,
|
||||
},
|
||||
attachment.storagePath,
|
||||
);
|
||||
} else {
|
||||
const currentConfig = await getAttachmentStorageConfig(attachment.uploadedBy);
|
||||
if (
|
||||
currentConfig.mode === "oss" &&
|
||||
currentConfig.ossProvider === "aliyun_oss" &&
|
||||
currentConfig.aliyunOss
|
||||
) {
|
||||
buffer = await readAliyunOssObjectBuffer(currentConfig.aliyunOss, attachment.storagePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
excerpt = extractAttachmentTextExcerpt(buffer);
|
||||
}
|
||||
} catch {
|
||||
excerpt = "";
|
||||
}
|
||||
return {
|
||||
textExcerpt: excerpt,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAttachmentAnalysisPrompt(params: {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
attachment: NonNullable<Awaited<ReturnType<typeof getProjectAttachment>>>["attachment"];
|
||||
messageBody: string;
|
||||
requestedBy: string;
|
||||
requestedByAccount: string;
|
||||
attachmentDownloadUrl: string;
|
||||
attachmentTextExcerpt?: string;
|
||||
}) {
|
||||
const attachment = params.attachment;
|
||||
return [
|
||||
"你是 Boss 控制台的附件分析主 Agent。",
|
||||
"请根据下面的附件元数据、可下载地址,以及你能实际读取到的附件内容进行分析。",
|
||||
"如果需要读取原始文件,请优先使用 curl、python 或系统工具下载并检查该附件。",
|
||||
"如果你无法直接读取原始内容,不要假装已经看过内容,必须明确说明限制,并只基于你实际拿到的内容给出判断。",
|
||||
"输出要求:",
|
||||
"1. 一句话结论",
|
||||
"2. 内容摘要或可见特征",
|
||||
"3. 风险或异常",
|
||||
"4. 建议动作",
|
||||
"",
|
||||
`projectId: ${params.projectId}`,
|
||||
`projectName: ${params.projectName}`,
|
||||
`requestedBy: ${params.requestedBy}`,
|
||||
`requestedByAccount: ${params.requestedByAccount}`,
|
||||
`attachmentId: ${attachment.attachmentId}`,
|
||||
`fileName: ${attachment.fileName}`,
|
||||
`mimeType: ${attachment.mimeType}`,
|
||||
`fileSizeBytes: ${attachment.fileSizeBytes}`,
|
||||
`attachmentKind: ${attachment.attachmentKind}`,
|
||||
`storageBackend: ${attachment.storageBackend}`,
|
||||
`storagePath: ${attachment.storagePath}`,
|
||||
`previewAvailable: ${attachment.previewAvailable ? "yes" : "no"}`,
|
||||
`uploadedAt: ${attachment.uploadedAt}`,
|
||||
`uploadedBy: ${attachment.uploadedBy}`,
|
||||
`analysisState: ${attachment.analysisState}`,
|
||||
`downloadUrl: ${params.attachmentDownloadUrl}`,
|
||||
"",
|
||||
"原始消息:",
|
||||
params.messageBody || "无",
|
||||
"",
|
||||
"如果附件可以直接解析文本,请优先基于文本内容进行判断。",
|
||||
"文本摘录:",
|
||||
params.attachmentTextExcerpt || "无可直接内嵌的文本摘录,请按需下载原文件后自行读取。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function queueAttachmentAnalysisTask(params: {
|
||||
projectId: string;
|
||||
attachmentId: string;
|
||||
requestMessageId: string;
|
||||
requestedBy: string;
|
||||
requestedByAccount: string;
|
||||
markProcessing?: boolean;
|
||||
publicBaseUrl?: string;
|
||||
}) {
|
||||
const record = await getProjectAttachment(params.projectId, params.attachmentId);
|
||||
if (!record) {
|
||||
throw new Error("ATTACHMENT_NOT_FOUND");
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
const taskId = `mastertask-${randomBytes(4).toString("hex")}`;
|
||||
const attachmentDownloadToken = randomBytes(12).toString("hex");
|
||||
const attachmentDownloadExpiresAt = new Date(Date.now() + 30 * 60_000).toISOString();
|
||||
const attachmentDownloadUrl =
|
||||
`${params.publicBaseUrl?.trim() || resolveBossPublicBaseUrl()}/api/v1/attachments/${record.attachment.attachmentId}/download` +
|
||||
`?taskId=${taskId}&token=${attachmentDownloadToken}`;
|
||||
const attachmentContext = await buildAttachmentAnalysisContext({
|
||||
attachment: record.attachment,
|
||||
});
|
||||
const task = await queueMasterAgentTask({
|
||||
taskId,
|
||||
projectId: record.project.id,
|
||||
taskType: "attachment_analysis",
|
||||
requestMessageId: params.requestMessageId,
|
||||
requestText: `分析附件《${record.attachment.fileName}》`,
|
||||
executionPrompt: buildAttachmentAnalysisPrompt({
|
||||
projectId: record.project.id,
|
||||
projectName: record.project.name,
|
||||
attachment: record.attachment,
|
||||
messageBody: record.message.body,
|
||||
requestedBy: params.requestedBy,
|
||||
requestedByAccount: params.requestedByAccount,
|
||||
attachmentDownloadUrl,
|
||||
attachmentTextExcerpt: attachmentContext.textExcerpt,
|
||||
}),
|
||||
requestedBy: params.requestedBy,
|
||||
requestedByAccount: params.requestedByAccount,
|
||||
deviceId: state.user.boundDeviceId || "mac-studio",
|
||||
attachmentId: record.attachment.attachmentId,
|
||||
attachmentFileName: record.attachment.fileName,
|
||||
attachmentDownloadToken,
|
||||
attachmentDownloadExpiresAt,
|
||||
attachmentDownloadUrl,
|
||||
attachmentTextExcerpt: attachmentContext.textExcerpt,
|
||||
});
|
||||
|
||||
if (params.markProcessing) {
|
||||
await updateAttachmentAnalysisResult({
|
||||
projectId: params.projectId,
|
||||
attachmentId: params.attachmentId,
|
||||
status: "processing",
|
||||
});
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
export async function validateAiAccountConnection(accountId: string) {
|
||||
const account = await getRuntimeAiAccountById(accountId);
|
||||
if (!account) {
|
||||
|
||||
121
src/lib/boss-storage-aliyun-oss.ts
Normal file
121
src/lib/boss-storage-aliyun-oss.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import OSS from "ali-oss";
|
||||
import type { UserAttachmentStorageConfig } from "@/lib/boss-data";
|
||||
import type { AttachmentStorageProvider, StoreAttachmentParams, StoredAttachmentRecord } from "@/lib/boss-storage";
|
||||
import { sanitizeFileName } from "@/lib/boss-attachments";
|
||||
import { decryptStorageSecret } from "@/lib/boss-storage-secrets";
|
||||
|
||||
type AliyunOssConfig = NonNullable<UserAttachmentStorageConfig["aliyunOss"]>;
|
||||
|
||||
function accountStorageSegment(account: string) {
|
||||
const normalized = account.normalize("NFKC").trim();
|
||||
const digest = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
||||
return `acct-${digest}`;
|
||||
}
|
||||
|
||||
function normalizePrefix(prefix?: string) {
|
||||
const trimmed = (prefix ?? "boss/").trim();
|
||||
const normalized = trimmed.replace(/^\/+|\/+$/g, "");
|
||||
return normalized || "boss";
|
||||
}
|
||||
|
||||
function buildObjectKey(params: StoreAttachmentParams, prefix?: string) {
|
||||
const now = new Date();
|
||||
return path.posix.join(
|
||||
normalizePrefix(prefix),
|
||||
accountStorageSegment(params.account),
|
||||
String(now.getUTCFullYear()),
|
||||
String(now.getUTCMonth() + 1).padStart(2, "0"),
|
||||
`${params.messageId}-${sanitizeFileName(params.fileName)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createAliyunOssClient(config: AliyunOssConfig) {
|
||||
if (!config.enabled) {
|
||||
throw new Error("ALIYUN_OSS_NOT_ENABLED");
|
||||
}
|
||||
if (
|
||||
!config.accessKeyId.trim() ||
|
||||
!config.accessKeySecretEncrypted.trim() ||
|
||||
!config.bucket.trim() ||
|
||||
!config.endpoint.trim() ||
|
||||
!config.region.trim()
|
||||
) {
|
||||
throw new Error("ALIYUN_OSS_CONFIG_INCOMPLETE");
|
||||
}
|
||||
|
||||
const accessKeySecret = await decryptStorageSecret(config.accessKeySecretEncrypted);
|
||||
return new OSS({
|
||||
accessKeyId: config.accessKeyId,
|
||||
accessKeySecret,
|
||||
bucket: config.bucket,
|
||||
endpoint: config.endpoint,
|
||||
region: config.region,
|
||||
});
|
||||
}
|
||||
|
||||
export function createAliyunOssStorageProvider(config: AliyunOssConfig): AttachmentStorageProvider {
|
||||
return {
|
||||
backend: "aliyun_oss",
|
||||
async storeAttachment(params: StoreAttachmentParams): Promise<StoredAttachmentRecord> {
|
||||
const client = await createAliyunOssClient(config);
|
||||
const objectKey = buildObjectKey(params, config.prefix);
|
||||
await client.put(objectKey, params.buffer, {
|
||||
headers: {
|
||||
"Content-Type": params.mimeType || "application/octet-stream",
|
||||
},
|
||||
});
|
||||
return {
|
||||
storageBackend: "aliyun_oss",
|
||||
storagePath: objectKey,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAliyunOssSignedDownloadUrl(
|
||||
config: AliyunOssConfig,
|
||||
objectKey: string,
|
||||
expiresSeconds = 300,
|
||||
) {
|
||||
const client = await createAliyunOssClient(config);
|
||||
return client.signatureUrl(objectKey, {
|
||||
expires: expiresSeconds,
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
export async function readAliyunOssObjectBuffer(
|
||||
config: AliyunOssConfig,
|
||||
objectKey: string,
|
||||
): Promise<Buffer<ArrayBufferLike>> {
|
||||
const client = await createAliyunOssClient(config);
|
||||
const response = await client.get(objectKey);
|
||||
const content = response.content;
|
||||
if (Buffer.isBuffer(content)) {
|
||||
return content;
|
||||
}
|
||||
if (typeof content === "string") {
|
||||
return Buffer.from(content);
|
||||
}
|
||||
if (content instanceof ArrayBuffer) {
|
||||
return Buffer.from(content);
|
||||
}
|
||||
if (content && typeof (content as ArrayBufferView).byteLength === "number") {
|
||||
const view = content as ArrayBufferView;
|
||||
return Buffer.from(view.buffer, view.byteOffset, view.byteLength);
|
||||
}
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
|
||||
export async function validateAliyunOssConfig(config: AliyunOssConfig) {
|
||||
const client = await createAliyunOssClient(config);
|
||||
await client.getBucketInfo(config.bucket);
|
||||
return {
|
||||
provider: "aliyun_oss" as const,
|
||||
bucket: config.bucket,
|
||||
endpoint: config.endpoint,
|
||||
region: config.region,
|
||||
};
|
||||
}
|
||||
100
src/lib/boss-storage-config.ts
Normal file
100
src/lib/boss-storage-config.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { UserAttachmentStorageConfig } from "@/lib/boss-data";
|
||||
import { encryptStorageSecret } from "@/lib/boss-storage-secrets";
|
||||
|
||||
export interface AttachmentStorageConfigPatch {
|
||||
mode?: UserAttachmentStorageConfig["mode"];
|
||||
ossProvider?: UserAttachmentStorageConfig["ossProvider"];
|
||||
aliyunOss?: {
|
||||
enabled?: boolean;
|
||||
accessKeyId?: string;
|
||||
accessKeySecret?: string;
|
||||
bucket?: string;
|
||||
endpoint?: string;
|
||||
region?: string;
|
||||
prefix?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function trimText(value: string | undefined) {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
function normalizePrefix(prefix: string | undefined, fallback?: string) {
|
||||
const candidate = prefix?.trim() || fallback?.trim() || "boss/";
|
||||
return candidate || "boss/";
|
||||
}
|
||||
|
||||
function buildAliyunOssBaseConfig(existing?: UserAttachmentStorageConfig["aliyunOss"]) {
|
||||
return {
|
||||
enabled: existing?.enabled ?? false,
|
||||
accessKeyId: existing?.accessKeyId ?? "",
|
||||
accessKeySecretEncrypted: existing?.accessKeySecretEncrypted ?? "",
|
||||
bucket: existing?.bucket ?? "",
|
||||
endpoint: existing?.endpoint ?? "",
|
||||
region: existing?.region ?? "",
|
||||
prefix: existing?.prefix ?? "boss/",
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyAttachmentStorageConfigPatch(
|
||||
existing: UserAttachmentStorageConfig,
|
||||
patch: AttachmentStorageConfigPatch,
|
||||
): Promise<UserAttachmentStorageConfig> {
|
||||
const mode = patch.mode ?? existing.mode;
|
||||
const effectiveOssProvider =
|
||||
mode === "oss" ? patch.ossProvider ?? existing.ossProvider ?? "aliyun_oss" : existing.ossProvider;
|
||||
|
||||
const nextAliyun = buildAliyunOssBaseConfig(existing.aliyunOss);
|
||||
if (patch.aliyunOss) {
|
||||
if (patch.aliyunOss.enabled !== undefined) {
|
||||
nextAliyun.enabled = patch.aliyunOss.enabled;
|
||||
}
|
||||
if (patch.aliyunOss.accessKeyId !== undefined) {
|
||||
nextAliyun.accessKeyId = trimText(patch.aliyunOss.accessKeyId);
|
||||
}
|
||||
if (patch.aliyunOss.bucket !== undefined) {
|
||||
nextAliyun.bucket = trimText(patch.aliyunOss.bucket);
|
||||
}
|
||||
if (patch.aliyunOss.endpoint !== undefined) {
|
||||
nextAliyun.endpoint = trimText(patch.aliyunOss.endpoint);
|
||||
}
|
||||
if (patch.aliyunOss.region !== undefined) {
|
||||
nextAliyun.region = trimText(patch.aliyunOss.region);
|
||||
}
|
||||
if (patch.aliyunOss.prefix !== undefined) {
|
||||
nextAliyun.prefix = normalizePrefix(patch.aliyunOss.prefix, nextAliyun.prefix);
|
||||
}
|
||||
if (patch.aliyunOss.accessKeySecret !== undefined) {
|
||||
nextAliyun.accessKeySecretEncrypted = await encryptStorageSecret(
|
||||
patch.aliyunOss.accessKeySecret,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "oss") {
|
||||
if (effectiveOssProvider !== "aliyun_oss") {
|
||||
throw new Error("ALIYUN_OSS_PROVIDER_REQUIRED");
|
||||
}
|
||||
if (
|
||||
!nextAliyun.accessKeyId ||
|
||||
!nextAliyun.accessKeySecretEncrypted ||
|
||||
!nextAliyun.bucket ||
|
||||
!nextAliyun.endpoint ||
|
||||
!nextAliyun.region
|
||||
) {
|
||||
throw new Error("ALIYUN_OSS_CONFIG_INCOMPLETE");
|
||||
}
|
||||
nextAliyun.enabled = true;
|
||||
}
|
||||
|
||||
return {
|
||||
account: existing.account,
|
||||
mode,
|
||||
ossProvider: effectiveOssProvider,
|
||||
aliyunOss: nextAliyun,
|
||||
updatedAt: new Date().toISOString(),
|
||||
validatedAt: patch.mode !== undefined || patch.ossProvider !== undefined || patch.aliyunOss
|
||||
? undefined
|
||||
: existing.validatedAt,
|
||||
};
|
||||
}
|
||||
96
src/lib/boss-storage-secrets.ts
Normal file
96
src/lib/boss-storage-secrets.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const STORAGE_SECRET_KEY_FILE = "storage-secret.key";
|
||||
const STORAGE_SECRET_VERSION = "v1";
|
||||
const STORAGE_SECRET_ALGORITHM = "aes-256-gcm";
|
||||
|
||||
function detectRuntimeRoot(startDir: string) {
|
||||
let current = startDir;
|
||||
while (true) {
|
||||
if (
|
||||
path.basename(current) === "boss" &&
|
||||
path.basename(path.dirname(current)) === "code"
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return startDir;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRuntimeRoot() {
|
||||
if (process.env.BOSS_RUNTIME_ROOT?.trim()) {
|
||||
return path.resolve(process.env.BOSS_RUNTIME_ROOT);
|
||||
}
|
||||
if (process.env.BOSS_STATE_FILE?.trim()) {
|
||||
return path.dirname(path.dirname(path.resolve(process.env.BOSS_STATE_FILE)));
|
||||
}
|
||||
return detectRuntimeRoot(process.cwd());
|
||||
}
|
||||
|
||||
function resolveStorageSecretKeyPath() {
|
||||
return path.join(resolveRuntimeRoot(), "data", STORAGE_SECRET_KEY_FILE);
|
||||
}
|
||||
|
||||
function deriveKeyFromSource(source: string) {
|
||||
return createHash("sha256").update(source.normalize("NFKC")).digest();
|
||||
}
|
||||
|
||||
async function getStorageSecretKey() {
|
||||
if (process.env.BOSS_STORAGE_SECRET_KEY?.trim()) {
|
||||
return deriveKeyFromSource(process.env.BOSS_STORAGE_SECRET_KEY);
|
||||
}
|
||||
|
||||
const keyPath = resolveStorageSecretKeyPath();
|
||||
try {
|
||||
const existing = await readFile(keyPath, "utf8");
|
||||
return deriveKeyFromSource(existing.trim());
|
||||
} catch {
|
||||
const generated = randomBytes(32).toString("hex");
|
||||
await mkdir(path.dirname(keyPath), { recursive: true });
|
||||
await writeFile(keyPath, `${generated}\n`, { encoding: "utf8", mode: 0o600 });
|
||||
return deriveKeyFromSource(generated);
|
||||
}
|
||||
}
|
||||
|
||||
export async function encryptStorageSecret(plainText: string) {
|
||||
const normalized = plainText.trim();
|
||||
if (!normalized) {
|
||||
throw new Error("ALIYUN_OSS_SECRET_REQUIRED");
|
||||
}
|
||||
const key = await getStorageSecretKey();
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv(STORAGE_SECRET_ALGORITHM, key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(normalized, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return [
|
||||
STORAGE_SECRET_VERSION,
|
||||
iv.toString("hex"),
|
||||
tag.toString("hex"),
|
||||
encrypted.toString("hex"),
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export async function decryptStorageSecret(cipherText: string) {
|
||||
const [version, ivHex, tagHex, payloadHex] = cipherText.split(":");
|
||||
if (version !== STORAGE_SECRET_VERSION || !ivHex || !tagHex || !payloadHex) {
|
||||
throw new Error("INVALID_STORAGE_SECRET_FORMAT");
|
||||
}
|
||||
const key = await getStorageSecretKey();
|
||||
const decipher = createDecipheriv(
|
||||
STORAGE_SECRET_ALGORITHM,
|
||||
key,
|
||||
Buffer.from(ivHex, "hex"),
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(tagHex, "hex"));
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(Buffer.from(payloadHex, "hex")),
|
||||
decipher.final(),
|
||||
]);
|
||||
return decrypted.toString("utf8");
|
||||
}
|
||||
86
src/lib/boss-storage-server-file.ts
Normal file
86
src/lib/boss-storage-server-file.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { StoreAttachmentParams, StoredAttachmentRecord } from "@/lib/boss-storage";
|
||||
import { sanitizeFileName } from "@/lib/boss-attachments";
|
||||
|
||||
function detectRuntimeRoot(startDir: string) {
|
||||
let current = startDir;
|
||||
while (true) {
|
||||
if (
|
||||
path.basename(current) === "boss" &&
|
||||
path.basename(path.dirname(current)) === "code"
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return startDir;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRuntimeRoot() {
|
||||
if (process.env.BOSS_RUNTIME_ROOT?.trim()) {
|
||||
return path.resolve(process.env.BOSS_RUNTIME_ROOT);
|
||||
}
|
||||
if (process.env.BOSS_STATE_FILE?.trim()) {
|
||||
return path.dirname(path.dirname(path.resolve(process.env.BOSS_STATE_FILE)));
|
||||
}
|
||||
return detectRuntimeRoot(process.cwd());
|
||||
}
|
||||
|
||||
function getUploadsRoot() {
|
||||
return path.resolve(resolveRuntimeRoot(), "data", "uploads");
|
||||
}
|
||||
|
||||
function accountStorageSegment(account: string) {
|
||||
const normalized = account.normalize("NFKC").trim();
|
||||
const digest = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
||||
return `acct-${digest}`;
|
||||
}
|
||||
|
||||
function normalizeUploadsRelativePath(storagePath: string) {
|
||||
const normalized = storagePath.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
return normalized.replace(/^data\/uploads\/+/, "");
|
||||
}
|
||||
|
||||
function resolvePathWithinUploadsRoot(storagePath: string) {
|
||||
const uploadsRoot = getUploadsRoot();
|
||||
const candidate = path.resolve(uploadsRoot, normalizeUploadsRelativePath(storagePath));
|
||||
const relative = path.relative(uploadsRoot, candidate);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
throw new Error("ATTACHMENT_STORAGE_PATH_OUTSIDE_UPLOADS_ROOT");
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export async function storeServerFileAttachment(
|
||||
params: StoreAttachmentParams,
|
||||
): Promise<StoredAttachmentRecord> {
|
||||
const now = new Date();
|
||||
const relativePath = path.posix.join(
|
||||
"data",
|
||||
"uploads",
|
||||
accountStorageSegment(params.account),
|
||||
String(now.getUTCFullYear()),
|
||||
String(now.getUTCMonth() + 1).padStart(2, "0"),
|
||||
`${params.messageId}-${sanitizeFileName(params.fileName)}`,
|
||||
);
|
||||
const absolutePath = resolvePathWithinUploadsRoot(relativePath);
|
||||
await mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await writeFile(absolutePath, params.buffer);
|
||||
return {
|
||||
storageBackend: "server_file",
|
||||
storagePath: relativePath,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveServerFileAttachmentAbsolutePath(storagePath: string) {
|
||||
return resolvePathWithinUploadsRoot(storagePath);
|
||||
}
|
||||
|
||||
export async function readServerFileAttachmentBuffer(storagePath: string) {
|
||||
return readFile(resolveServerFileAttachmentAbsolutePath(storagePath));
|
||||
}
|
||||
108
src/lib/boss-storage.ts
Normal file
108
src/lib/boss-storage.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { AttachmentStorageBackend, UserAttachmentStorageConfig } from "@/lib/boss-data";
|
||||
import { createAliyunOssStorageProvider, validateAliyunOssConfig } from "@/lib/boss-storage-aliyun-oss";
|
||||
import { storeServerFileAttachment } from "@/lib/boss-storage-server-file";
|
||||
|
||||
export interface StoreAttachmentParams {
|
||||
account: string;
|
||||
messageId: string;
|
||||
attachmentId: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface StoredAttachmentRecord {
|
||||
storageBackend: AttachmentStorageBackend;
|
||||
storagePath: string;
|
||||
}
|
||||
|
||||
export interface AttachmentStorageProvider {
|
||||
backend: AttachmentStorageBackend;
|
||||
storeAttachment(params: StoreAttachmentParams): Promise<StoredAttachmentRecord>;
|
||||
}
|
||||
|
||||
export interface SanitizedUserAttachmentStorageConfig {
|
||||
account: string;
|
||||
mode: UserAttachmentStorageConfig["mode"];
|
||||
ossProvider?: UserAttachmentStorageConfig["ossProvider"];
|
||||
aliyunOss?: {
|
||||
enabled: boolean;
|
||||
accessKeyId: string;
|
||||
accessKeySecretConfigured: boolean;
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
region: string;
|
||||
prefix?: string;
|
||||
};
|
||||
updatedAt: string;
|
||||
validatedAt?: string;
|
||||
}
|
||||
|
||||
const serverFileProvider: AttachmentStorageProvider = {
|
||||
backend: "server_file",
|
||||
async storeAttachment(params) {
|
||||
return storeServerFileAttachment(params);
|
||||
},
|
||||
};
|
||||
|
||||
export function getAttachmentStorageProvider(
|
||||
config: UserAttachmentStorageConfig,
|
||||
) {
|
||||
if (config.mode === "server_file") {
|
||||
return serverFileProvider;
|
||||
}
|
||||
|
||||
if (config.mode === "oss" && config.ossProvider === "aliyun_oss" && config.aliyunOss) {
|
||||
return createAliyunOssStorageProvider(config.aliyunOss);
|
||||
}
|
||||
|
||||
throw new Error("ATTACHMENT_STORAGE_MODE_NOT_SUPPORTED");
|
||||
}
|
||||
|
||||
export function sanitizeAttachmentStorageConfig(
|
||||
config: UserAttachmentStorageConfig,
|
||||
): SanitizedUserAttachmentStorageConfig {
|
||||
return {
|
||||
account: config.account,
|
||||
mode: config.mode,
|
||||
ossProvider: config.ossProvider,
|
||||
aliyunOss: config.aliyunOss
|
||||
? {
|
||||
enabled: config.aliyunOss.enabled,
|
||||
accessKeyId: config.aliyunOss.accessKeyId,
|
||||
accessKeySecretConfigured: Boolean(config.aliyunOss.accessKeySecretEncrypted),
|
||||
bucket: config.aliyunOss.bucket,
|
||||
endpoint: config.aliyunOss.endpoint,
|
||||
region: config.aliyunOss.region,
|
||||
prefix: config.aliyunOss.prefix,
|
||||
}
|
||||
: undefined,
|
||||
updatedAt: config.updatedAt,
|
||||
validatedAt: config.validatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeStorageError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_STORAGE_ERROR";
|
||||
switch (message) {
|
||||
case "ALIYUN_OSS_NOT_ENABLED":
|
||||
return "阿里 OSS 尚未启用。";
|
||||
case "ALIYUN_OSS_CONFIG_INCOMPLETE":
|
||||
return "阿里 OSS 配置不完整,请补齐 AccessKey、Bucket、Endpoint 和 Region。";
|
||||
case "ALIYUN_OSS_SECRET_REQUIRED":
|
||||
return "请填写 AccessKey Secret。";
|
||||
case "ALIYUN_OSS_PROVIDER_REQUIRED":
|
||||
return "当前只支持阿里 OSS,请先选择阿里 OSS。";
|
||||
case "INVALID_STORAGE_SECRET_FORMAT":
|
||||
return "当前 OSS 密钥格式无效,请重新填写 AccessKey Secret。";
|
||||
default:
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateAttachmentStorageConfig(config: UserAttachmentStorageConfig) {
|
||||
if (config.mode !== "oss" || config.ossProvider !== "aliyun_oss" || !config.aliyunOss) {
|
||||
throw new Error("ALIYUN_OSS_NOT_ENABLED");
|
||||
}
|
||||
return validateAliyunOssConfig(config.aliyunOss);
|
||||
}
|
||||
Reference in New Issue
Block a user