Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/live-verify-platform-kernel.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,26 @@ jobs:
IMAGE_STUDIO_API_KEY: ${{ secrets.IMAGE_STUDIO_API_KEY }}
IMAGE_STUDIO_TEXT_MODEL_ID: ${{ secrets.IMAGE_STUDIO_TEXT_MODEL_ID }}
IMAGE_STUDIO_IMAGE_MODEL_ID: ${{ secrets.IMAGE_STUDIO_IMAGE_MODEL_ID }}
IMAGE_STUDIO_VERIFY_RESULTS_DIR: .tmp/live-verify-results
run: node ./scripts/live-verify.mjs

- name: Upload live verify results
if: always()
uses: actions/upload-artifact@v4
with:
name: live-verify-platform-kernel-results
path: .tmp/live-verify-results
if-no-files-found: warn

- name: Render live verify job summary
if: always()
run: |
if [ -f .tmp/live-verify-results/live-verify.json ]; then
node ./scripts/render-live-verify-summary.mjs .tmp/live-verify-results/live-verify.json >> "$GITHUB_STEP_SUMMARY"
else
{
echo "## Live Verify Platform Kernel"
echo
echo "- result file missing: \`.tmp/live-verify-results/live-verify.json\`"
} >> "$GITHUB_STEP_SUMMARY"
fi
294 changes: 290 additions & 4 deletions .github/workflows/release.yml

Large diffs are not rendered by default.

65 changes: 65 additions & 0 deletions .github/workflows/verify-issue-close-tooling.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Verify Issue Close Tooling

on:
workflow_dispatch:

jobs:
verify-issue-close-tooling:
name: Verify issue close tooling
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"

- name: Prepare verification output directory
run: |
mkdir -p .tmp/issue-close-results
mkdir -p .tmp/issue-close-export

- name: Verify issue close tooling
env:
GITHUB_TOKEN: ${{ github.token }}
IMAGE_STUDIO_VERIFY_RESULTS_DIR: .tmp/issue-close-results
run: node ./scripts/verify-issue-close-tooling.mjs

- name: Export issue close comment bundle
if: always()
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
node ./scripts/issue-close-helper.mjs export .tmp/issue-close-export > .tmp/issue-close-export/export-result.json

- name: Upload issue close tooling results
if: always()
uses: actions/upload-artifact@v4
with:
name: verify-issue-close-tooling-results
path: .tmp/issue-close-results
if-no-files-found: warn

- name: Upload issue close comment bundle
if: always()
uses: actions/upload-artifact@v4
with:
name: verify-issue-close-comment-bundle
path: .tmp/issue-close-export
if-no-files-found: warn

- name: Render issue close tooling job summary
if: always()
run: |
if [ -f .tmp/issue-close-results/issue-close-tooling.json ]; then
node ./scripts/render-issue-close-summary.mjs .tmp/issue-close-results/issue-close-tooling.json .tmp/issue-close-export/manifest.json >> "$GITHUB_STEP_SUMMARY"
else
{
echo "## Issue Close Tooling"
echo
echo "- result file missing: \`.tmp/issue-close-results/issue-close-tooling.json\`"
} >> "$GITHUB_STEP_SUMMARY"
fi
29 changes: 29 additions & 0 deletions .github/workflows/verify-platform-kernel.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,34 @@ jobs:
mkdir -p .tmp/android-home/.android
printf 'sdk.dir=%s\n' "$ANDROID_SDK_ROOT" > android-shell/local.properties

- name: Prepare verification output directory
run: mkdir -p .tmp/verify-results

- name: Verify local platform kernel suite
env:
IMAGE_STUDIO_SKIP_RUNTIME_UPDATE_PROBE: "1"
IMAGE_STUDIO_INCLUDE_ISSUE_CLOSE_VERIFY: "1"
IMAGE_STUDIO_SKIP_ISSUE_CLOSE_GITHUB_SYNC: "1"
IMAGE_STUDIO_VERIFY_RESULTS_DIR: .tmp/verify-results
run: node ./scripts/verify-local-platform-kernel.mjs

- name: Upload verification results
if: always()
uses: actions/upload-artifact@v4
with:
name: verify-platform-kernel-results
path: .tmp/verify-results
if-no-files-found: warn

- name: Render verification job summary
if: always()
run: |
if [ -f .tmp/verify-results/platform-kernel-summary.json ]; then
node ./scripts/render-verify-platform-summary.mjs .tmp/verify-results/platform-kernel-summary.json >> "$GITHUB_STEP_SUMMARY"
else
{
echo "## Verify Platform Kernel"
echo
echo "- summary file missing: \`.tmp/verify-results/platform-kernel-summary.json\`"
} >> "$GITHUB_STEP_SUMMARY"
fi
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Image Studio 面向 OpenAI 兼容图像上游,重点解决长时间图像推

项目不内置任何默认上游。首次启动需要你自己填写 BASE_URL、API Key、文本模型与图像模型。

当前没有独立部署的在线 Web 版。仓库里的浏览器预览主要用于前端调试和 target platform 预览,不等同于可直接对外提供服务的 SaaS Web 端。

## 快速上手

1. 安装应用
Expand Down Expand Up @@ -52,7 +54,11 @@ Image Studio 面向 OpenAI 兼容图像上游,重点解决长时间图像推
| 应用展示、界面截图、能力概览 | [docs/showcase.md](./docs/showcase.md) |
| 安装包下载、平台差异、产物选择 | [docs/packages.md](./docs/packages.md) |
| 功能清单、平台能力、快捷键 | [docs/features.md](./docs/features.md) |
| 图生图批处理的入口、流程、输入输出规则 | [docs/batch-img2img/README.md](./docs/batch-img2img/README.md) |
| 当前 issue 处理进展与待验证项 | [docs/issue-progress.md](./docs/issue-progress.md) |
| 可直接复用的 issue 关单评论模板 | [docs/issue-close-comments.md](./docs/issue-close-comments.md) |
| 源码构建、验证脚本、CI 产物链路 | [docs/build.md](./docs/build.md) |
| 真机 / 真实上游手工验证矩阵 | [docs/manual-verification.md](./docs/manual-verification.md) |
| 首次配置、API 形态选择、参数策略 | [docs/usage.md](./docs/usage.md) |
| 提 Issue 前自查、数据存储位置、524/504、模型权限、字段兼容问题 | [docs/troubleshooting.md](./docs/troubleshooting.md) |
| 仓库结构、前端分层、内核 / Worker / Android 关系 | [docs/project-structure.md](./docs/project-structure.md) |
Expand Down
25 changes: 25 additions & 0 deletions android-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,31 @@ cd android-shell
./gradlew assembleRelease
```

Local verification without a connected device:

```bash
cd ..
node scripts/verify-local-android-shell.mjs
```

This script assembles the debug APK, checks `versionName` / `versionCode`,
verifies the APK signature, confirms the built frontend assets are embedded
into the package, and runs the Android JVM unit tests for shell-side parsing
logic.

If you already have a device or emulator attached over `adb`, you can also ask
the script to install and launch the debug APK:

```bash
IMAGE_STUDIO_ANDROID_DEVICE_SMOKE=1 node scripts/verify-local-android-shell.mjs
```

Optionally pin a specific device:

```bash
IMAGE_STUDIO_ANDROID_DEVICE_SMOKE=1 IMAGE_STUDIO_ANDROID_SERIAL=<serial> node scripts/verify-local-android-shell.mjs
```

MuMu emulator debugging:

- See `../docs/mumu-android-debug.md` for the shared ADB connection,
Expand Down
1 change: 1 addition & 0 deletions android-shell/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ dependencies {
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.webkit:webkit:1.11.0")
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20250517")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ class AndroidImageStudioBridge(
companion object {
private const val maxDialogReadBytes: Long = 50L * 1024L * 1024L
private const val maxPreviewEdge = 384
private const val generationConnectTimeoutMs = 30_000
private const val generationReadTimeoutMs = 8 * 60 * 1000
}

@JavascriptInterface
Expand Down Expand Up @@ -435,8 +437,12 @@ class AndroidImageStudioBridge(
val connection = openHttpConnection(url, proxyMode, proxyUrl).apply {
requestMethod = method
instanceFollowRedirects = true
connectTimeout = 30_000
readTimeout = 180_000
connectTimeout = generationConnectTimeoutMs
// Multi-image / high-resolution jobs can legitimately run
// for minutes after the last preview frame. Keeping the
// Android native HTTP bridge at only 180s makes it more
// likely to cut the stream before the final image arrives.
readTimeout = generationReadTimeoutMs
doInput = true
}
httpRequests[requestKey] = connection
Expand All @@ -457,22 +463,33 @@ class AndroidImageStudioBridge(
}
val status = connection.responseCode
val stream = if (status >= 400) connection.errorStream else connection.inputStream
var streamResult: NativeHttpStreamResultSnapshot? = null
val body = if (streamLines) {
val lines = mutableListOf<String>()
val bodyBuilder = StringBuilder()
stream?.bufferedReader()?.useLines { sequence ->
sequence.forEach { line ->
lines.add(line)
emitNativeProgress(requestKey, mapOf("line" to line))
bodyBuilder.append(line).append('\n')
if (streamResult == null) {
streamResult = extractNativeHttpStreamResult(line)
}
buildNativeHttpStreamProgressPayload(line)?.let { progressPayload ->
emitNativeProgress(requestKey, progressPayload)
}
}
}
if (lines.isEmpty()) "" else lines.joinToString(separator = "\n", postfix = "\n")
bodyBuilder.toString()
} else {
stream?.bufferedReader()?.use { it.readText() } ?: ""
}
val rawPath = if (body.isNotBlank()) writeNativeHttpRawBody(body, connection.contentType ?: "", requestKey) else ""
val result = mapOf(
"status" to status,
"body" to body,
"body" to if (streamResult != null) "" else body,
"contentType" to (connection.contentType ?: ""),
"rawPath" to rawPath,
"resultImageB64" to (streamResult?.imageB64 ?: ""),
"revisedPrompt" to (streamResult?.revisedPrompt ?: ""),
"sourceEvent" to (streamResult?.sourceEvent ?: ""),
)
resolve(requestId, result)
} catch (error: Exception) {
Expand All @@ -484,6 +501,14 @@ class AndroidImageStudioBridge(
throw EarlyResolve()
}

private fun writeNativeHttpRawBody(body: String, contentType: String, requestKey: String): String {
val logDir = File(getOutputDir(), "log").apply { mkdirs() }
val ext = if (contentType.lowercase(Locale.US).contains("json")) "json" else "txt"
val file = File(logDir, "android-http-${timestamp()}-${sanitizeFileName(requestKey, "request")}.$ext")
file.writeText(body)
return file.absolutePath
}

private fun runProbeUpstream(requestId: String, payload: JSONObject): Nothing {
val baseUrl = validateProbeBaseUrl(payload.optString("baseURL"))
val apiKey = payload.optString("apiKey").trim()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package top.gptcodex.imagestudio.android

import org.json.JSONObject

internal data class NativeHttpStreamResultSnapshot(
val imageB64: String,
val revisedPrompt: String,
val sourceEvent: String,
)

internal fun extractNativeHttpStreamResult(line: String): NativeHttpStreamResultSnapshot? {
val trimmed = line.trim()
if (!trimmed.startsWith("data: ")) return null
val payload = trimmed.removePrefix("data: ").trim()
if (payload.isBlank() || payload == "[DONE]") return null
return try {
val event = JSONObject(payload)
when (event.optString("type")) {
"response.output_item.done" -> {
val item = event.optJSONObject("item")
if (item?.optString("type") == "image_generation_call") {
val result = item.optString("result")
if (result.isNotBlank()) {
NativeHttpStreamResultSnapshot(
imageB64 = result,
revisedPrompt = item.optString("revised_prompt"),
sourceEvent = "final",
)
} else null
} else null
}
"image_generation.completed", "image_edit.completed" -> {
val result = event.optString("b64_json")
if (result.isNotBlank()) {
NativeHttpStreamResultSnapshot(
imageB64 = result,
revisedPrompt = "",
sourceEvent = "images_api",
)
} else null
}
else -> null
}
} catch (_: Exception) {
null
}
}

internal fun buildNativeHttpStreamProgressPayload(line: String): Any? {
val trimmed = line.trim()
if (!trimmed.startsWith("data: ")) {
return mapOf("line" to line)
}
val payload = trimmed.removePrefix("data: ").trim()
if (payload.isBlank() || payload == "[DONE]") {
return mapOf("line" to line)
}
return try {
val event = JSONObject(payload)
when (event.optString("type")) {
"response.image_generation_call.partial_image" -> mapOf(
"event" to mapOf(
"type" to "response.image_generation_call.partial_image",
"partial_image_b64" to event.optString("partial_image_b64"),
"revised_prompt" to event.optString("revised_prompt"),
"partial_image_index" to if (event.has("partial_image_index")) event.optInt("partial_image_index", -1) else -1,
),
)
"image_generation.partial_image", "image_edit.partial_image" -> mapOf(
"event" to mapOf(
"type" to event.optString("type"),
"b64_json" to event.optString("b64_json"),
"partial_image_index" to if (event.has("partial_image_index")) event.optInt("partial_image_index", -1) else -1,
),
)
"response.output_item.done" -> {
val item = event.optJSONObject("item")
if (item?.optString("type") == "image_generation_call" && item.optString("result").isNotBlank()) {
null
} else {
mapOf("line" to line)
}
}
"image_generation.completed", "image_edit.completed" -> {
if (event.optString("b64_json").isNotBlank()) null else mapOf("line" to line)
}
else -> mapOf("line" to line)
}
} catch (_: Exception) {
mapOf("line" to line)
}
}
Loading
Loading