14 Commits

Author SHA1 Message Date
kris
e051a49f7a chore: publish attachment storage release v2.5.0 2026-03-29 17:22:07 +08:00
kris
5fb75b50b4 fix: harden attachment analysis delivery 2026-03-29 17:10:58 +08:00
kris
88ab2d011a feat: add attachment analysis access links 2026-03-29 17:06:54 +08:00
kris
18dc7c6120 fix: stabilize attachment upload and storage flows 2026-03-29 16:59:10 +08:00
kris
1e476a2097 android: add attachment composer flow 2026-03-29 16:27:04 +08:00
kris
9e4b64ba9e Implement attachment analysis task flow 2026-03-29 16:21:05 +08:00
kris
8273340f7f feat(web): add me storage page 2026-03-29 16:20:25 +08:00
kris
3307f79162 feat: add aliyun oss storage config 2026-03-29 16:05:25 +08:00
kris
de23a6e921 fix: harden attachment access and file paths 2026-03-29 15:47:07 +08:00
kris
aa75506364 feat: add server file attachment pipeline 2026-03-29 15:26:06 +08:00
kris
c3900a11ec fix: align attachment storage model 2026-03-29 15:18:27 +08:00
kris
4262c8fb5c feat: add attachment storage config model 2026-03-29 15:11:17 +08:00
kris
e4ff24a18f docs: add attachment storage implementation plan 2026-03-29 15:06:16 +08:00
kris
3cb4405b14 docs: add attachment storage and ai processing spec 2026-03-29 15:03:02 +08:00
46 changed files with 7042 additions and 102 deletions

View File

@@ -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 会话

View File

@@ -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"
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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

View File

@@ -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"

View File

@@ -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());
}
}

View File

@@ -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) {}
}
}

View File

@@ -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() {

View File

@@ -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. 继续开发时的工作原则

View File

@@ -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
- 完整的附件详情页与富预览器
- 完整的多端用户会话系统与刷新令牌体系

View File

@@ -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 收件链路还没有在生产账号上完成最终验收

View File

@@ -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 覆盖
- 阿里 OSSTask 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`

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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"
}

Binary file not shown.

Binary file not shown.

View 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 });
}

View 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 });
}

View 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");

View 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");

View 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");

View 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),
});
}

View File

@@ -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 });
}
}

View 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`,
});
}

View 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 },
);
}
}

View 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 },
);
}
}

View File

@@ -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="账号与安全"

View 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>
);
}

View 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]">
OSSAccessKey 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]">
BucketEndpoint Region
</div>
) : null}
</div>
);
}

View 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
View 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...[已截断]`;
}

View File

@@ -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";
}

View File

@@ -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) {

View 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,
};
}

View 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,
};
}

View 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");
}

View 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
View 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);
}