fix: stabilize attachment upload and storage flows
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
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 "image".equals(sourceType) || "video".equals(sourceType);
|
||||
return ProjectChatUiState.requiresAttachmentConfirmation(sourceType);
|
||||
}
|
||||
|
||||
public static final class PendingAttachment {
|
||||
@@ -14,22 +16,22 @@ public final class AttachmentComposerState {
|
||||
public final String fileName;
|
||||
public final String mimeType;
|
||||
public final long fileSizeBytes;
|
||||
public final byte[] bytes;
|
||||
@Nullable public final Uri uri;
|
||||
|
||||
public PendingAttachment(
|
||||
String sourceType,
|
||||
String fileName,
|
||||
String mimeType,
|
||||
long fileSizeBytes,
|
||||
byte[] bytes
|
||||
@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, bytes == null ? 0 : bytes.length);
|
||||
this.bytes = bytes == null ? new byte[0] : bytes.clone();
|
||||
this.fileSizeBytes = Math.max(fileSizeBytes, 0L);
|
||||
this.uri = uri;
|
||||
}
|
||||
|
||||
public boolean requiresConfirmation() {
|
||||
|
||||
@@ -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;
|
||||
@@ -107,6 +109,22 @@ public class BossApiClient {
|
||||
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");
|
||||
@@ -121,7 +139,7 @@ public class BossApiClient {
|
||||
outputStream,
|
||||
boundary,
|
||||
"file",
|
||||
bytes == null ? new byte[0] : bytes,
|
||||
inputStream,
|
||||
fileName,
|
||||
mimeType
|
||||
);
|
||||
@@ -139,6 +157,25 @@ public class BossApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -380,7 +417,7 @@ public class BossApiClient {
|
||||
OutputStream outputStream,
|
||||
String boundary,
|
||||
String fieldName,
|
||||
byte[] bytes,
|
||||
InputStream inputStream,
|
||||
String fileName,
|
||||
String contentType
|
||||
) throws IOException {
|
||||
@@ -398,10 +435,57 @@ public class BossApiClient {
|
||||
? "application/octet-stream"
|
||||
: contentType) + "\r\n\r\n").getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
outputStream.write(bytes == null ? new byte[0] : bytes);
|
||||
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";
|
||||
@@ -427,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");
|
||||
@@ -503,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,8 +711,12 @@ public final class BossUi {
|
||||
String fileName,
|
||||
@Nullable String detail,
|
||||
@Nullable String status,
|
||||
@Nullable String summary,
|
||||
@Nullable String actionLabel,
|
||||
@Nullable View.OnClickListener actionListener,
|
||||
@Nullable String meta,
|
||||
boolean outgoing
|
||||
boolean outgoing,
|
||||
@Nullable View.OnClickListener cardClickListener
|
||||
) {
|
||||
LinearLayout wrapper = buildMessageWrapper(context, senderLabel, meta, outgoing);
|
||||
|
||||
@@ -725,6 +729,11 @@ public final class BossUi {
|
||||
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);
|
||||
@@ -787,6 +796,32 @@ public final class BossUi {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -163,11 +167,14 @@ public final class ProjectChatUiState {
|
||||
}
|
||||
|
||||
public static String labelForAttachmentAnalysisState(@Nullable String analysisState) {
|
||||
if ("queued_auto".equals(analysisState) || "ready_manual".equals(analysisState)) {
|
||||
if ("queued_auto".equals(analysisState)) {
|
||||
return "自动分析排队中";
|
||||
}
|
||||
if ("ready_manual".equals(analysisState)) {
|
||||
return "待分析";
|
||||
}
|
||||
if ("processing".equals(analysisState)) {
|
||||
return "分析中";
|
||||
return "AI 分析中";
|
||||
}
|
||||
if ("completed".equals(analysisState)) {
|
||||
return "已分析";
|
||||
@@ -178,6 +185,17 @@ public final class ProjectChatUiState {
|
||||
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 "图片";
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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;
|
||||
@@ -16,16 +18,19 @@ 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.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -330,23 +335,60 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
if (isComposerBusy()) {
|
||||
return;
|
||||
}
|
||||
AlertDialog dialog = new AlertDialog.Builder(this)
|
||||
.setItems(new CharSequence[]{"图片", "视频", "文件"}, (sheet, which) -> {
|
||||
if (which == 0) {
|
||||
imagePickerLauncher.launch("image/*");
|
||||
} else if (which == 1) {
|
||||
videoPickerLauncher.launch("video/*");
|
||||
} else {
|
||||
filePickerLauncher.launch("*/*");
|
||||
}
|
||||
})
|
||||
.create();
|
||||
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().setGravity(Gravity.BOTTOM);
|
||||
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;
|
||||
@@ -419,12 +461,12 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
appendPendingOutgoingAttachment(attachment);
|
||||
scrollChatToBottom();
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
try (InputStream inputStream = openAttachmentInputStream(attachment)) {
|
||||
BossApiClient.ApiResponse uploadResponse = apiClient.uploadAttachment(
|
||||
projectId,
|
||||
attachment.fileName,
|
||||
attachment.mimeType,
|
||||
attachment.bytes,
|
||||
inputStream,
|
||||
attachment.sourceType
|
||||
);
|
||||
if (!uploadResponse.ok()) {
|
||||
@@ -433,19 +475,14 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
|
||||
String successMessage = "附件已发送";
|
||||
JSONObject uploadedAttachment = uploadResponse.json.optJSONObject("attachment");
|
||||
if (uploadedAttachment != null && attachment.requiresConfirmation()) {
|
||||
String attachmentId = uploadedAttachment.optString("attachmentId", "");
|
||||
if (uploadedAttachment != null) {
|
||||
String analysisState = uploadedAttachment.optString("analysisState", "");
|
||||
if (!TextUtils.isEmpty(attachmentId)
|
||||
&& ("ready_manual".equals(analysisState) || "failed".equals(analysisState))) {
|
||||
try {
|
||||
BossApiClient.ApiResponse analyzeResponse = apiClient.analyzeAttachment(projectId, attachmentId);
|
||||
successMessage = analyzeResponse.ok()
|
||||
? "附件已发送,分析已发起"
|
||||
: "附件已发送,分析稍后可用";
|
||||
} catch (Exception ignored) {
|
||||
successMessage = "附件已发送,分析稍后可用";
|
||||
}
|
||||
if ("queued_auto".equals(analysisState)) {
|
||||
successMessage = "附件已发送,自动分析排队中";
|
||||
} else if ("ready_manual".equals(analysisState)) {
|
||||
successMessage = "附件已发送,可手动发起分析";
|
||||
} else if ("failed".equals(analysisState)) {
|
||||
successMessage = "附件已发送,分析稍后可重试";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,9 +538,17 @@ 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(
|
||||
@@ -536,7 +581,7 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
);
|
||||
break;
|
||||
}
|
||||
bindMessageInteractions(messageView, messageId, body);
|
||||
bindMessageInteractions(messageView, messageId, body, messagePrimaryClick);
|
||||
return messageView;
|
||||
}
|
||||
|
||||
@@ -564,19 +609,34 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
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(attachment.optString("analysisState", "")),
|
||||
ProjectChatUiState.labelForAttachmentAnalysisState(analysisState),
|
||||
attachment.optString("analysisSummary", ""),
|
||||
actionLabel,
|
||||
actionListener,
|
||||
meta,
|
||||
outgoing
|
||||
outgoing,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private void bindMessageInteractions(View messageView, String messageId, String body) {
|
||||
private void bindMessageInteractions(
|
||||
View messageView,
|
||||
String messageId,
|
||||
String body,
|
||||
@Nullable View.OnClickListener defaultClickListener
|
||||
) {
|
||||
if (messageView == null || TextUtils.isEmpty(messageId)) {
|
||||
return;
|
||||
}
|
||||
@@ -585,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);
|
||||
@@ -810,7 +873,11 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
detail,
|
||||
"发送中",
|
||||
null,
|
||||
true
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
null
|
||||
);
|
||||
appendContent(pendingOutgoingBubble);
|
||||
}
|
||||
@@ -933,15 +1000,8 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
private AttachmentComposerState.PendingAttachment readPendingAttachment(Uri uri, String sourceType) throws Exception {
|
||||
String fileName = resolveAttachmentFileName(uri);
|
||||
String mimeType = resolveAttachmentMimeType(uri, sourceType);
|
||||
byte[] bytes;
|
||||
try (InputStream inputStream = getContentResolver().openInputStream(uri)) {
|
||||
if (inputStream == null) {
|
||||
throw new IllegalStateException("无法打开附件输入流");
|
||||
}
|
||||
bytes = readAllBytes(inputStream);
|
||||
}
|
||||
long fileSize = resolveAttachmentFileSize(uri, bytes.length);
|
||||
return new AttachmentComposerState.PendingAttachment(sourceType, fileName, mimeType, fileSize, bytes);
|
||||
long fileSize = resolveAttachmentFileSize(uri, 0L);
|
||||
return new AttachmentComposerState.PendingAttachment(sourceType, fileName, mimeType, fileSize, uri);
|
||||
}
|
||||
|
||||
private String resolveAttachmentFileName(Uri uri) {
|
||||
@@ -1001,14 +1061,104 @@ public class ProjectDetailActivity extends BossScreenActivity {
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
private byte[] readAllBytes(InputStream inputStream) throws Exception {
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, read);
|
||||
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);
|
||||
}
|
||||
return outputStream.toByteArray();
|
||||
}
|
||||
|
||||
static ChromeBindings buildChromeBindings(
|
||||
|
||||
@@ -14,9 +14,10 @@ public class AttachmentComposerStateTest {
|
||||
"现场照片.png",
|
||||
"image/png",
|
||||
4096L,
|
||||
new byte[] {1, 2, 3}
|
||||
null
|
||||
);
|
||||
|
||||
assertTrue(ProjectChatUiState.requiresAttachmentConfirmation(attachment.sourceType));
|
||||
assertTrue(attachment.requiresConfirmation());
|
||||
}
|
||||
|
||||
@@ -28,9 +29,10 @@ public class AttachmentComposerStateTest {
|
||||
"巡检录屏.mp4",
|
||||
"video/mp4",
|
||||
8192L,
|
||||
new byte[] {4, 5, 6}
|
||||
null
|
||||
);
|
||||
|
||||
assertTrue(ProjectChatUiState.requiresAttachmentConfirmation(attachment.sourceType));
|
||||
assertTrue(attachment.requiresConfirmation());
|
||||
}
|
||||
|
||||
@@ -42,9 +44,10 @@ public class AttachmentComposerStateTest {
|
||||
"日报.xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
16384L,
|
||||
new byte[] {7, 8, 9}
|
||||
null
|
||||
);
|
||||
|
||||
assertFalse(ProjectChatUiState.requiresAttachmentConfirmation(attachment.sourceType));
|
||||
assertFalse(attachment.requiresConfirmation());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -3,41 +3,24 @@ 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";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), "boss-attachment-analysis-"));
|
||||
const stateFile = path.join(runtimeDir, "data", "boss-state.json");
|
||||
const require = createRequire(import.meta.url);
|
||||
const taskBaseCommit = "3307f7916220b74a8e7d0d8e8b2b12f888d0632a";
|
||||
const sourceStateFile = path.join(rootDir, "data", "boss-state.json");
|
||||
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeDir;
|
||||
process.env.BOSS_STATE_FILE = stateFile;
|
||||
process.env.BOSS_AUTH_AUTO_LOGIN = "0";
|
||||
|
||||
const { NextRequest } = require("next/server");
|
||||
const authLoginRoute = require(path.join(rootDir, ".next/standalone/.next/server/app/api/auth/login/route.js"));
|
||||
const attachmentsRoute = require(
|
||||
path.join(rootDir, ".next/standalone/.next/server/app/api/v1/projects/[projectId]/attachments/route.js"),
|
||||
);
|
||||
const analyzeRoute = require(
|
||||
path.join(
|
||||
rootDir,
|
||||
".next/standalone/.next/server/app/api/v1/projects/[projectId]/attachments/[attachmentId]/analyze/route.js",
|
||||
),
|
||||
);
|
||||
|
||||
const loginHandler = authLoginRoute.routeModule.userland.POST;
|
||||
const uploadHandler = attachmentsRoute.routeModule.userland.POST;
|
||||
const analyzeHandler = analyzeRoute.routeModule.userland.POST;
|
||||
|
||||
async function invokeRoute(handler, url, init = {}, context) {
|
||||
const request = new NextRequest(url, {
|
||||
method: init.method ?? "GET",
|
||||
headers: init.headers,
|
||||
body: init.body,
|
||||
});
|
||||
return handler(request, context);
|
||||
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) {
|
||||
@@ -47,20 +30,73 @@ function parseCookieValue(setCookieHeader, cookieName) {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
async function loginAsAdmin() {
|
||||
const response = await invokeRoute(
|
||||
loginHandler,
|
||||
"http://localhost/api/auth/login",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
account: "17600003315",
|
||||
password: "boss123456",
|
||||
method: "password",
|
||||
}),
|
||||
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_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");
|
||||
@@ -68,74 +104,135 @@ async function loginAsAdmin() {
|
||||
return { cookie, payload };
|
||||
}
|
||||
|
||||
async function uploadAttachment(cookie, projectId, fileName, type, bytes) {
|
||||
async function uploadAttachment(baseUrl, cookie, projectId, fileName, type, bytes) {
|
||||
const form = new FormData();
|
||||
form.set("file", new File([bytes], fileName, { type }));
|
||||
|
||||
const response = await invokeRoute(
|
||||
uploadHandler,
|
||||
`http://localhost/api/v1/projects/${projectId}/attachments`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { cookie: `boss_session=${cookie}` },
|
||||
body: form,
|
||||
const response = await fetch(`${baseUrl}/api/v1/projects/${projectId}/attachments`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: `boss_session=${cookie}`,
|
||||
},
|
||||
{ params: Promise.resolve({ projectId }) },
|
||||
);
|
||||
body: form,
|
||||
});
|
||||
assert.equal(response.status, 200, `upload ${fileName} should succeed`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
const { cookie } = await loginAsAdmin();
|
||||
|
||||
const textUpload = await uploadAttachment(
|
||||
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",
|
||||
);
|
||||
|
||||
const manualUpload = await uploadAttachment(
|
||||
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 analyzeResponse = await invokeRoute(
|
||||
analyzeHandler,
|
||||
`http://localhost/api/v1/projects/master-agent/attachments/${manualUpload.attachment.attachmentId}/analyze`,
|
||||
{
|
||||
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}` },
|
||||
},
|
||||
{
|
||||
params: Promise.resolve({
|
||||
projectId: "master-agent",
|
||||
attachmentId: manualUpload.attachment.attachmentId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert.equal(analyzeResponse.status, 200, "manual analyze should succeed");
|
||||
const analyzePayload = await analyzeResponse.json();
|
||||
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",
|
||||
);
|
||||
headers: {
|
||||
cookie: `boss_session=${cookie}`,
|
||||
},
|
||||
});
|
||||
assert.equal(response.status, 200, "manual analyze should succeed");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
console.log("attachment analysis validation passed");
|
||||
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",
|
||||
);
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ 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, readState } from "@/lib/boss-data";
|
||||
import { getAttachmentById, getAttachmentStorageConfig, 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";
|
||||
@@ -29,11 +30,23 @@ export async function GET(
|
||||
return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (record.attachment.storageBackend !== "server_file") {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "UNSUPPORTED_ATTACHMENT_STORAGE_BACKEND" },
|
||||
{ status: 501 },
|
||||
if (record.attachment.storageBackend === "aliyun_oss") {
|
||||
const storageConfig = await getAttachmentStorageConfig(record.attachment.uploadedBy);
|
||||
if (storageConfig.mode !== "oss" || storageConfig.ossProvider !== "aliyun_oss" || !storageConfig.aliyunOss) {
|
||||
return NextResponse.json({ ok: false, message: "ATTACHMENT_STORAGE_CONFIG_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
const signedUrl = await getAliyunOssSignedDownloadUrl(
|
||||
storageConfig.aliyunOss,
|
||||
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;
|
||||
|
||||
@@ -2,13 +2,21 @@ 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 } from "@/lib/boss-storage";
|
||||
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 = sanitizeAttachmentStorageConfig(await getAttachmentStorageConfig(session.account));
|
||||
const config = await getStorageConfigForSession(session.account);
|
||||
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
|
||||
@@ -64,6 +64,7 @@ export function AttachmentStorageClient({
|
||||
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("");
|
||||
@@ -97,15 +98,24 @@ export function AttachmentStorageClient({
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||||
const result = (await response.json()) as {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
config?: SanitizedUserAttachmentStorageConfig;
|
||||
};
|
||||
setBusyKey(null);
|
||||
setMessage(result.ok ? "附件存储配置已保存。" : result.message ?? "保存失败。");
|
||||
if (result.ok) {
|
||||
const nextConfig = result.config ?? currentConfig;
|
||||
setCurrentConfig(nextConfig);
|
||||
setDraft(draftFromConfig(nextConfig));
|
||||
setMessage("附件存储配置已保存。");
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
setMessage(result.message ?? "保存失败。");
|
||||
}
|
||||
|
||||
const modeLabel = draft.mode === "server_file" ? "服务器文件存储" : "OSS";
|
||||
const modeLabel = currentConfig.mode === "server_file" ? "服务器文件存储" : "OSS";
|
||||
const buttonLabel =
|
||||
draft.mode === "server_file"
|
||||
? "切回服务器文件存储"
|
||||
@@ -120,10 +130,10 @@ export function AttachmentStorageClient({
|
||||
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">
|
||||
当前模式:<span className="font-semibold text-[#111111]">{modeLabel}</span>
|
||||
<br />
|
||||
绑定账号:{config.account}
|
||||
绑定账号:{currentConfig.account}
|
||||
<br />
|
||||
{draft.mode === "oss"
|
||||
? `OSS 提供方:阿里 OSS · 密钥${config.aliyunOss?.accessKeySecretConfigured ? "已保存" : "未保存"}`
|
||||
{currentConfig.mode === "oss"
|
||||
? `OSS 提供方:阿里 OSS · 密钥${currentConfig.aliyunOss?.accessKeySecretConfigured ? "已保存" : "未保存"}`
|
||||
: "附件将继续写入服务器文件存储。"}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
|
||||
@@ -3625,8 +3625,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,18 @@ export function createAliyunOssStorageProvider(config: AliyunOssConfig): Attachm
|
||||
};
|
||||
}
|
||||
|
||||
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 validateAliyunOssConfig(config: AliyunOssConfig) {
|
||||
const client = await createAliyunOssClient(config);
|
||||
await client.getBucketInfo(config.bucket);
|
||||
|
||||
Reference in New Issue
Block a user